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

A lightweight snapshot of this page. Sliders update the preview immediately.

Color Scheme
Background Color
Text Color
Selection Background
Selection Text
Font
Text Stroke
${theme.textStroke || 0}
Engine for ${escapeHtml(hostname)}

Force a specific engine on sites where the default breaks

Disabled Sites
Enabled Sites (optional)

Leave empty to darken all sites. A matching !pattern always excludes a site.

Automatically skip sites that already have a dark theme

Site CSS Fixes (${escapeHtml(hostname)})

Supports: INVERT, CSS, IGNORE INLINE STYLE, NO INVERT, REMOVE BG

Automation
Activate
Deactivate

${locationInfo}

Day
Night

Keeps DarkModer enabled and swaps theme palettes on the automation schedule.

Gently compresses bright theme colors when this display reports HDR capability.

Theme Presets
${presetsList}
Import/Export
About

DarkModer v${CONFIG.version} — a userscript recreation of Dark Reader.

`; }, /** * Build the filter used by the current-page thumbnail. * @param {Object} theme - Theme settings * @returns {string} CSS filter value */ getPreviewFilter(theme = {}) { const filters = []; const addPercentage = (name, value, baseline = 0) => { const numeric = Number(value); if (Number.isFinite(numeric) && numeric !== baseline) { filters.push(`${name}(${numeric}%)`); } }; addPercentage('brightness', theme.brightness, 100); addPercentage('contrast', theme.contrast, 100); addPercentage('sepia', theme.sepia); addPercentage('grayscale', theme.grayscale); return filters.join(' ') || 'none'; }, /** * Collect accessible page styles for the isolated preview document. * Cross-origin stylesheets that cannot be read are intentionally * skipped; the page can still be represented by its DOM snapshot. * @returns {{links: string, css: string}} Preview styles */ collectPreviewStyles() { const links = []; const css = []; try { const linkNodes = document.querySelectorAll?.('link[rel="stylesheet"][href]') || []; for (const link of Array.from(linkNodes).slice(0, 12)) { const href = link.href || link.getAttribute('href'); if (href) { links.push(``); } } const styleNodes = document.querySelectorAll?.('style') || []; for (const style of Array.from(styleNodes).slice(0, 20)) { const id = style.id || ''; const className = style.className?.toString() || ''; if (id.startsWith('darkmoder-') || className.includes('darkmoder-')) { continue; } css.push(style.textContent || ''); } } catch (e) { // A partially constructed document should still get a preview. } return { links: links.join(''), css: css.join('\n') }; }, /** * Remove active content and bound the size of a page snapshot. * @param {HTMLElement} clone - Cloned body element */ sanitizePreviewClone(clone) { if (!clone?.querySelectorAll) { return; } const removeSelectors = [ 'script', 'noscript', 'iframe', 'object', 'embed', 'canvas', 'video', 'audio', 'source', 'track', 'darkmoder-ui', '#darkmoder-ui' ]; for (const node of clone.querySelectorAll(removeSelectors.join(','))) { node.remove(); } let count = 0; for (const element of clone.querySelectorAll('*')) { count++; if (count > 450) { element.remove(); continue; } for (const attribute of Array.from(element.attributes || [])) { if (attribute.name.toLowerCase().startsWith('on') || attribute.name === 'srcdoc') { element.removeAttribute(attribute.name); } } if (element.tagName?.toLowerCase() === 'img') { element.setAttribute('loading', 'lazy'); element.setAttribute('decoding', 'async'); } } }, /** * Create a sandboxed HTML snapshot of the current page. * @returns {string} iframe srcdoc payload */ buildPreviewDocument() { let bodyHTML = '
Preview unavailable
'; try { const clone = document.body?.cloneNode(true); if (clone) { this.sanitizePreviewClone(clone); bodyHTML = clone.innerHTML || bodyHTML; } } catch (e) { // Keep the lightweight fallback document. } const { links, css } = this.collectPreviewStyles(); const previewCSS = ` * { box-sizing: border-box; } html, body { margin: 0 !important; min-width: 0 !important; max-width: none !important; overflow: hidden !important; } body { min-height: 100vh; padding: 8px !important; } img, svg, table { max-width: 100% !important; } img { height: auto !important; } input, textarea, select, button { max-width: 100% !important; } `; const safeCSS = `${previewCSS}\n${css}`.replace(/<\/style/gi, '<\\/style'); const safeBody = bodyHTML .replace(/<\/script/gi, '<\\/script') .slice(0, 160000); return `${links}${safeBody}`; }, /** * Update the preview document and its current theme filter. * @param {boolean} force - Rebuild the page snapshot */ updatePreview(force = false) { const root = this.shadowRoot || this.container; const frame = root?.querySelector('#dm-page-preview'); if (!frame) { return; } if (force || !this.previewDocument) { this.previewDocument = this.buildPreviewDocument(); } if (frame.srcdoc !== this.previewDocument) { frame.srcdoc = this.previewDocument; } frame.style.filter = this.getPreviewFilter(this.getSiteTheme()); }, /** * Build a preview document with one engine's generated base CSS. * @param {string} engineName - Engine to render * @param {Object} theme - Theme settings * @returns {string} iframe srcdoc payload */ buildEngineComparisonDocument(engineName, theme) { const supported = ['filter', 'filterPlus', 'dynamic', 'static']; const engine = supported.includes(engineName) ? engineName : 'dynamic'; let css = ''; let svgFilter = ''; switch (engine) { case 'filter': css = FilterEngine.create(theme); break; case 'filterPlus': css = FilterPlusEngine.create(theme); svgFilter = FilterPlusEngine.getSVGFilter(); break; case 'static': css = StaticEngine.create(theme); break; case 'dynamic': default: css = DynamicEngine.createRootCSS(theme); break; } const safeCSS = String(css).replace(/<\/style/gi, '<\\/style'); let preview = this.buildPreviewDocument().replace( '', `` ); if (svgFilter) { preview = preview.replace('', `
${svgFilter}
`); } return preview; }, /** * Open two isolated snapshots for quick engine comparison. * @param {string} firstEngine - Left engine * @param {string} secondEngine - Right engine * @returns {HTMLElement|null} Comparison overlay */ showEngineComparison(firstEngine = 'dynamic', secondEngine = 'filter') { if (typeof document === 'undefined' || !document.body) { return null; } this.closeEngineComparison(); const engines = ['filter', 'filterPlus', 'dynamic', 'static']; const first = engines.includes(firstEngine) ? firstEngine : 'dynamic'; const second = engines.includes(secondEngine) ? secondEngine : 'filter'; const theme = this.getSiteTheme(); const overlay = document.createElement('div'); overlay.id = 'darkmoder-engine-comparison'; overlay.style.cssText = [ 'position:fixed', 'inset:0', 'z-index:2147483646', 'display:flex', 'flex-direction:column', 'padding:12px', 'gap:8px', 'background:#111318', 'color:#e8e6e3', 'font:12px -apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif' ].join(';'); const toolbar = document.createElement('div'); toolbar.style.cssText = 'display:flex;align-items:center;justify-content:space-between;gap:8px;min-height:28px;'; const title = document.createElement('strong'); title.textContent = `DarkModer engine comparison: ${first} vs ${second}`; const close = document.createElement('button'); close.type = 'button'; close.textContent = 'Close'; close.style.cssText = 'padding:6px 12px;border:1px solid #45475a;border-radius:5px;background:#313244;color:#cdd6f4;cursor:pointer;'; close.addEventListener('click', () => this.closeEngineComparison()); toolbar.append(title, close); const columns = document.createElement('div'); columns.style.cssText = 'display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:8px;min-height:0;flex:1;'; for (const engine of [first, second]) { const card = document.createElement('div'); card.style.cssText = 'display:flex;flex-direction:column;gap:4px;min-width:0;min-height:0;background:#1e1e2e;border:1px solid #45475a;border-radius:7px;padding:6px;'; const label = document.createElement('strong'); label.textContent = engine; const frame = document.createElement('iframe'); frame.title = `${engine} engine preview`; frame.sandbox = 'allow-same-origin'; frame.style.cssText = 'display:block;width:100%;height:100%;min-height:0;border:0;border-radius:4px;background:#fff;'; frame.srcdoc = this.buildEngineComparisonDocument(engine, theme); card.append(label, frame); columns.appendChild(card); } overlay.append(toolbar, columns); document.body.appendChild(overlay); this.comparisonOverlay = overlay; return overlay; }, closeEngineComparison() { if (this.comparisonOverlay) { this.comparisonOverlay.remove(); this.comparisonOverlay = null; } }, /** * Show a short in-panel status message. * @param {string} message - Message to show * @param {string} type - info or error */ notify(message, type = 'info') { const root = this.shadowRoot || this.container; if (!root) { return; } const existing = root.querySelector('.dm-toast'); if (existing) { existing.remove(); } const toast = document.createElement('div'); toast.className = `dm-toast ${type}`; toast.textContent = message; root.appendChild(toast); setTimeout(() => toast.remove(), 2600); }, /** * Download generated text as a local file. * @param {string} filename - File name * @param {string} text - Text content * @param {string} type - MIME type */ downloadText(filename, text, type = 'text/plain') { const blob = new Blob([text], { type }); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = filename; a.click(); URL.revokeObjectURL(url); }, /** * Copy text, falling back to a generated download when clipboard is unavailable. * @param {string} text - Text to copy * @param {string} successMessage - Success toast text * @param {string} fallbackFilename - Download filename for fallback */ async copyText(text, successMessage, fallbackFilename) { try { if (navigator.clipboard?.writeText) { await navigator.clipboard.writeText(text); this.notify(successMessage); return; } const textarea = document.createElement('textarea'); textarea.value = text; textarea.style.position = 'fixed'; textarea.style.left = '-9999px'; document.body.appendChild(textarea); textarea.select(); const copied = document.execCommand('copy'); textarea.remove(); if (copied) { this.notify(successMessage); return; } } catch (e) { // Fall through to download fallback. } this.downloadText(fallbackFilename, text); this.notify('Clipboard unavailable; downloaded instead'); }, /** * Build the disabled-site export payload. * @returns {Object} Site-list export */ buildSiteListPayload() { return { version: CONFIG.version, exportedAt: new Date().toISOString(), enabledFor: DarkModer.settings.enabledFor || [], disabledFor: DarkModer.settings.disabledFor || [], disabledSites: DarkModer.settings.disabledSites || [] }; }, /** * Build a PowerShell one-liner for publishing the site list as a GitHub Gist. * @returns {string} Gist command */ buildGistCommand() { const payload = JSON.stringify(this.buildSiteListPayload(), null, 2); const encoded = btoa(unescape(encodeURIComponent(payload))); return `powershell -NoProfile -Command "$json=[Text.Encoding]::UTF8.GetString([Convert]::FromBase64String('${encoded}')); Set-Content -LiteralPath .\\darkmoder-sites.json -Value $json -Encoding UTF8; gh gist create .\\darkmoder-sites.json --desc 'DarkModer site list' --public"`; }, /** * Build a JSON object entry for the current site's local fix. * @returns {string} JSON block */ buildSiteFixJSONBlock() { const hostname = window.location.hostname; const textarea = (this.shadowRoot || this.container)?.querySelector('#dm-site-fixes'); const fixText = textarea?.value || ''; const normalized = SiteFixesProcessor.normalizeFix(fixText) || { css: '' }; return JSON.stringify({ [hostname]: normalized }, null, 2).replace(/^\{\n|\n\}$/g, ''); }, /** * Bind event handlers for UI elements */ bindEvents() { const root = this.shadowRoot || this.container; const $ = sel => root.querySelector(sel); const $$ = sel => root.querySelectorAll(sel); // Close button $('#dm-close').addEventListener('click', () => this.close()); // --- Quick-action toolbar --- // Toggle on/off $('#dm-quick-toggle').addEventListener('click', () => { DarkModer.toggle(); this.refreshHTML(); }); // Toggle site $('#dm-quick-site').addEventListener('click', () => { DarkModer.toggleSite(); this.refreshHTML(); }); // Disable this site for the current tab session only $('#dm-quick-once').addEventListener('click', () => { DarkModer.disableOnce(); this.notify('Disabled for this session'); this.refreshHTML(); }); // Cycle engines const engineCycle = ['dynamic', 'filter', 'filterPlus', 'static']; $('#dm-quick-cycle').addEventListener('click', () => { const current = DarkModer.settings.theme.engine || 'dynamic'; const idx = engineCycle.indexOf(current); const next = engineCycle[(idx + 1) % engineCycle.length]; DarkModer.settings.theme.engine = next; DarkModer.saveSettings(); DarkModer.apply(); this.refreshHTML(); }); // Reset $('#dm-quick-reset').addEventListener('click', () => { DarkModer.resetSettings(); this.refreshHTML(); }); // --- Per-site engine override --- $('#dm-site-engine').addEventListener('change', (e) => { const hostname = window.location.hostname; if (!DarkModer.settings.siteSettings) { DarkModer.settings.siteSettings = {}; } if (e.target.value) { if (!DarkModer.settings.siteSettings[hostname]) { DarkModer.settings.siteSettings[hostname] = {}; } DarkModer.settings.siteSettings[hostname].engine = e.target.value; } else { // Clear site engine override if (DarkModer.settings.siteSettings[hostname]) { delete DarkModer.settings.siteSettings[hostname].engine; if (Object.keys(DarkModer.settings.siteSettings[hostname]).length === 0) { delete DarkModer.settings.siteSettings[hostname]; } } } DarkModer.saveSettings(); DarkModer.apply(); }); // --- Site list search with fuzzy matching --- $('#dm-site-search').addEventListener('input', (e) => { const query = e.target.value.toLowerCase().trim(); const textarea = $('#dm-disabled-sites'); const allSites = (DarkModer.settings.disabledFor || []); if (!query) { textarea.value = allSites.join('\n'); return; } // Fuzzy match: check if all chars of query appear in order const fuzzyMatch = (str, q) => { let qi = 0; for (let si = 0; si < str.length && qi < q.length; si++) { if (str[si] === q[qi]) qi++; } return qi === q.length; }; const filtered = allSites.filter(site => fuzzyMatch(site.toLowerCase(), query)); textarea.value = filtered.join('\n'); }); // --- Export site list as JSON --- $('#dm-export-sites').addEventListener('click', () => { this.downloadText('darkmoder-sites.json', JSON.stringify(this.buildSiteListPayload(), null, 2), 'application/json'); this.notify('Site list exported'); }); // --- Import site list from JSON --- $('#dm-import-sites').addEventListener('click', () => { const input = document.createElement('input'); input.type = 'file'; input.accept = '.json'; input.onchange = (e) => { const file = e.target.files[0]; if (!file) return; const reader = new FileReader(); reader.onload = (ev) => { try { const imported = JSON.parse(ev.target.result); if (imported.enabledFor && Array.isArray(imported.enabledFor)) { const existing = new Set(DarkModer.settings.enabledFor || []); for (const site of imported.enabledFor) { existing.add(site); } DarkModer.settings.enabledFor = [...existing]; } if (imported.disabledFor && Array.isArray(imported.disabledFor)) { // Merge without duplicates const existing = new Set(DarkModer.settings.disabledFor || []); for (const site of imported.disabledFor) { existing.add(site); } DarkModer.settings.disabledFor = [...existing]; } if (imported.disabledSites && Array.isArray(imported.disabledSites)) { const existing = new Set(DarkModer.settings.disabledSites || []); for (const site of imported.disabledSites) { existing.add(site); } DarkModer.settings.disabledSites = [...existing]; } DarkModer.saveSettings(); this.refreshHTML(); this.notify('Site list imported'); } catch (err) { this.notify('Invalid site list file: ' + err.message, 'error'); } }; reader.readAsText(file); }; input.click(); }); $('#dm-copy-gist-command').addEventListener('click', () => { this.copyText(this.buildGistCommand(), 'Gist command copied', 'darkmoder-gist-command.ps1'); }); // 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); if (DarkModer.settings.enabledFor.length > 0 && !isURLMatched(location.href, DarkModer.settings.enabledFor)) { DarkModer.settings.enabledFor.push(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) }); }); }); $('#dm-preview-refresh').addEventListener('click', () => { this.updatePreview(true); this.notify('Preview refreshed'); }); // 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(); }); // Enabled sites allow a positive rule set with explicit exclusions. $('#dm-enabled-sites').addEventListener('change', (e) => { DarkModer.settings.enabledFor = e.target.value .split('\n') .map(s => s.trim()) .filter(Boolean); DarkModer.saveSettings(); if (DarkModer.settings.enabled) { DarkModer.apply(); } }); // 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(); this.notify('Site CSS override saved'); }); $('#dm-copy-site-fix-json').addEventListener('click', () => { this.copyText(this.buildSiteFixJSONBlock(), 'PR JSON block copied', 'darkmoder-site-fix.json'); }); // 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(); }); // Optional day/night palette schedule. $('#dm-theme-schedule').addEventListener('change', (e) => { if (!DarkModer.settings.automation.themeSchedule) { DarkModer.settings.automation.themeSchedule = { enabled: false, day: 'matte', night: 'deep' }; } DarkModer.settings.automation.themeSchedule.enabled = e.target.checked; $('#dm-theme-schedule-settings').style.display = e.target.checked ? 'block' : 'none'; DarkModer.saveSettings(); DarkModer.checkAutomation(); }); $('#dm-day-theme').addEventListener('change', (e) => { DarkModer.settings.automation.themeSchedule.day = e.target.value; DarkModer.saveSettings(); DarkModer.checkAutomation(); }); $('#dm-night-theme').addEventListener('change', (e) => { DarkModer.settings.automation.themeSchedule.night = e.target.value; DarkModer.saveSettings(); DarkModer.checkAutomation(); }); $('#dm-hdr-tone-mapping').addEventListener('change', (e) => { DarkModer.settings.hdrToneMapping = e.target.checked; DarkModer.saveSettings(); DarkModer.apply(); }); // Time settings $('#dm-time-on').addEventListener('change', (e) => { DarkModer.settings.time.activation = e.target.value; DarkModer.settings.automation.startTime = e.target.value; DarkModer.saveSettings(); DarkModer.checkAutomation(); }); $('#dm-time-off').addEventListener('change', (e) => { DarkModer.settings.time.deactivation = e.target.value; DarkModer.settings.automation.endTime = 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'; } }); // Save preset button $('#dm-save-preset').addEventListener('click', () => { const name = $('#dm-preset-name').value.trim(); if (!name) { this.notify('Enter a preset name', 'error'); 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 = ''; this.notify('Preset saved'); }); // Preset apply/delete buttons (delegated) root.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); DarkModer.settings.presets.splice(idx, 1); DarkModer.saveSettings(); this.updateUI(); this.notify('Preset deleted'); } }); // 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); if (DarkModer.importSettings(JSON.stringify(imported))) { this.updateUI(); this.notify('Settings imported'); } else { this.notify('Invalid settings file', 'error'); } } catch (err) { this.notify('Invalid settings file: ' + err.message, 'error'); } }; reader.readAsText(file); } }; input.click(); }); // Export generated CSS $('#dm-export-css').addEventListener('click', () => { const css = DynamicEngine.exportCSS(); if (!css || css.trim().length < 100) { this.notify('No generated CSS yet', 'error'); return; } this.downloadText(`darkmoder-${window.location.hostname}.css`, css, 'text/css'); this.notify('Generated CSS exported'); }); $('#dm-export-color-map').addEventListener('click', () => { const css = DarkModer.exportColorMapCSS(); this.downloadText(`darkmoder-color-map-${window.location.hostname}.css`, css, 'text/css'); this.notify('Color map exported'); }); // Reset all settings $('#dm-reset').addEventListener('click', () => { DarkModer.settings = { ...DEFAULT_SETTINGS }; DarkModer.settings.theme = { ...DEFAULT_THEME }; DarkModer.saveSettings(); DarkModer.apply(); this.updateUI(); this.notify('Settings reset'); }); // Close when clicking outside — listen on document document.addEventListener('click', (e) => { if (this.isOpen && !this.container.contains(e.target) && e.target !== this.container) { this.close(); } }); }, /** * Refresh the entire shadow DOM content (used after quick-action changes) */ refreshHTML() { if (!this.shadowRoot) return; // Preserve style element const styleEl = this.shadowRoot.querySelector('style'); // Remove old wrapper const oldWrapper = this.shadowRoot.querySelector('div'); if (oldWrapper) oldWrapper.remove(); // Re-create content const wrapper = document.createElement('div'); wrapper.innerHTML = this.getHTML(); this.shadowRoot.appendChild(wrapper); this.bindEvents(); this.updatePreview(true); }, /** * Update theme — accepts a partial object of changed properties * @param {Object} changes - Changed theme properties */ updateTheme(changes) { if (changes && typeof changes === 'object') { DarkModer.settings.theme = { ...DarkModer.settings.theme, ...changes }; } // Save and apply DarkModer.saveSettings(); DarkModer.apply(); this.updatePreview(); }, /** * Update UI controls from current settings */ updateUI() { const root = this.shadowRoot || this.container; if (!root) { return; } const $ = (sel) => root.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') || $('#dm-quick-site'); if (siteToggle) { const hostname = getURLHostname(location.href); const disabledSites = settings.disabledSites || []; const disabledFor = settings.disabledFor || []; const enabledFor = settings.enabledFor || []; const isDisabled = (enabledFor.length > 0 && !isURLMatched(location.href, enabledFor)) || isURLMatched(location.href, [...disabledSites, ...disabledFor]); siteToggle.classList.toggle('active', !isDisabled); siteToggle.textContent = siteToggle.id === 'dm-quick-site' ? `Site ${isDisabled ? 'OFF' : 'ON'}` : (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'; } const themeSchedule = settings.automation.themeSchedule || {}; const scheduleCheck = $('#dm-theme-schedule'); if (scheduleCheck) { scheduleCheck.checked = Boolean(themeSchedule.enabled); } const scheduleSettings = $('#dm-theme-schedule-settings'); if (scheduleSettings) { scheduleSettings.style.display = themeSchedule.enabled ? 'block' : 'none'; } const dayTheme = $('#dm-day-theme'); if (dayTheme) { dayTheme.value = themeSchedule.day || 'matte'; } const nightTheme = $('#dm-night-theme'); if (nightTheme) { nightTheme.value = themeSchedule.night || 'deep'; } const hdrToneMapping = $('#dm-hdr-tone-mapping'); if (hdrToneMapping) { hdrToneMapping.checked = Boolean(settings.hdrToneMapping); hdrToneMapping.disabled = !HDRToneMapper.isDisplayHDR(); } // 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 r = this.shadowRoot || this.container; const select = r?.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 r2 = this.shadowRoot || this.container; const container = r2?.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 root = this.shadowRoot || this.container; const firstSlider = root?.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() { this.closeEngineComparison(); if (this.container) { this.container.remove(); this.container = null; this.shadowRoot = null; } this.previewDocument = ''; 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, /** Hosts disabled until this page session ends */ sessionDisabledFor: new Set(), /** Site-specific settings cache */ currentSiteTheme: null, /** Base theme preserved while a scheduled palette is active */ scheduledThemeBase: 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(); LightPinManager.install(); // Load remote configs await this.loadRemoteConfigs(); // Apply theme if enabled if (this.settings.enabled) { this.apply(); } // 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 = []; } if (!Array.isArray(this.settings.enabledFor)) { this.settings.enabledFor = []; } // 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 }; } this.settings.automation.themeSchedule = deepMerge( { ...DEFAULT_SETTINGS.automation.themeSchedule }, this.settings.automation.themeSchedule || {} ); if (!SCHEDULED_THEME_PRESETS[this.settings.automation.themeSchedule.day]) { this.settings.automation.themeSchedule.day = 'matte'; } if (!SCHEDULED_THEME_PRESETS[this.settings.automation.themeSchedule.night]) { this.settings.automation.themeSchedule.night = 'deep'; } // 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 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(); }, /** * Resolve whether the current automation period is the night period. * @returns {{valid: boolean, night: boolean}} Automation state */ getAutomationNightState() { const automation = this.settings.automation || {}; const mode = automation.mode || 'disabled'; if (mode === 'system') { return { valid: true, night: Boolean(window.matchMedia?.('(prefers-color-scheme: dark)')?.matches) }; } if (mode === 'time') { const parseTime = (value) => { const parts = String(value || '').split(':').map(Number); if (parts.length !== 2 || !Number.isInteger(parts[0]) || !Number.isInteger(parts[1]) || parts[0] < 0 || parts[0] > 23 || parts[1] < 0 || parts[1] > 59) { return null; } return parts[0] * 60 + parts[1]; }; const start = parseTime(this.settings.time?.activation || automation.startTime || '18:00'); const end = parseTime(this.settings.time?.deactivation || automation.endTime || '09:00'); if (start === null || end === null) { return { valid: false, night: false }; } const now = new Date(); const current = now.getHours() * 60 + now.getMinutes(); const night = start < end ? current >= start && current < end : current >= start || current < end; return { valid: true, night }; } if (mode === 'location') { const lat = automation.latitude ?? this.settings.location?.latitude; const lon = automation.longitude ?? this.settings.location?.longitude; if (lat === null || lat === undefined || lon === null || lon === undefined || !Number.isFinite(Number(lat)) || !Number.isFinite(Number(lon))) { return { valid: false, night: false }; } const times = SunCalc.calculate(new Date(), Number(lat), Number(lon)); const now = new Date(); return { valid: true, night: now < times.sunrise || now >= times.sunset }; } return { valid: false, night: false }; }, /** * Apply the selected day/night palette while preserving other theme options. * @param {boolean} night - Whether the night palette should be active */ applyScheduledTheme(night) { const schedule = this.settings.automation?.themeSchedule || {}; const presetName = night ? schedule.night : schedule.day; const preset = SCHEDULED_THEME_PRESETS[presetName] || SCHEDULED_THEME_PRESETS.matte; if (!this.scheduledThemeBase) { this.scheduledThemeBase = { ...this.settings.theme }; } else { // Keep user changes to options not controlled by the palette. const paletteKeys = new Set(Object.keys(SCHEDULED_THEME_PRESETS.matte)); for (const key of Object.keys(this.settings.theme)) { if (!paletteKeys.has(key)) { this.scheduledThemeBase[key] = this.settings.theme[key]; } } } const nextTheme = { ...this.scheduledThemeBase, ...preset }; const changed = JSON.stringify(this.settings.theme) !== JSON.stringify(nextTheme) || !this.settings.enabled; if (!changed) { return; } this.settings.theme = nextTheme; this.settings.enabled = true; this.saveSettings(); this.apply(); }, /** Restore the user theme after scheduled palettes are disabled. */ restoreScheduledTheme() { if (!this.scheduledThemeBase) { return; } this.settings.theme = { ...this.scheduledThemeBase }; this.scheduledThemeBase = null; this.saveSettings(); if (this.settings.enabled) { this.apply(); } }, /** * Check automation rules and apply/remove or swap themes accordingly. */ checkAutomation() { const mode = this.settings.automation?.mode || 'disabled'; const schedule = this.settings.automation?.themeSchedule; if (mode === 'disabled' || mode === '') { this.restoreScheduledTheme(); return; } const state = this.getAutomationNightState(); if (schedule?.enabled) { if (state.valid) { this.applyScheduledTheme(state.night); } return; } this.restoreScheduledTheme(); // Apply or remove based on the night period. if (state.night && !this.settings.enabled) { this.settings.enabled = true; this.saveSettings(); this.apply(); } else if (!state.night && 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 effectiveTheme = this.getEffectiveTheme(); const bgColor = effectiveTheme.darkSchemeBackgroundColor || effectiveTheme.backgroundColor || '#181a1b'; 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) const runFullDetection = () => { 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(); } }); }; if (typeof requestIdleCallback === 'function') { requestIdleCallback(runFullDetection, { timeout: 1000 }); } else { setTimeout(runFullDetection, 100); } }, /** * Resolve an engine name from a theme object. * @param {Object} theme - Theme settings * @returns {string} Engine name */ getEngineName(theme = this.getEffectiveTheme()) { return theme.engine || (['filter', 'dynamic', 'filterPlus', 'static'][theme.mode]) || 'dynamic'; }, /** * Resolve the remote config file needed for an engine. * @param {string} engineName - Engine name * @returns {string|null} Config name */ getConfigNameForEngine(engineName) { if (engineName === 'filter' || engineName === 'filterPlus') { return 'inversionFixes'; } if (engineName === 'static') { return 'staticThemes'; } if (engineName === 'dynamic') { return 'dynamicFixes'; } return null; }, /** * Load the skip list and only the active engine's fix config. * Falls back to built-in configs on failure. */ async loadRemoteConfigs() { this.remoteConfig = this.remoteConfig || {}; try { if (!this.remoteConfig.darkSites) { this.remoteConfig.darkSites = await ConfigLoader.loadConfig('darkSites'); } await this.ensureRemoteConfigForEngine(this.getEngineName()); } catch (e) { if (CONFIG.debugMode) { console.log('[DarkModer] Failed to load remote configs, using bundled fallback:', e); } } }, /** * Ensure the active engine's remote fix config is loaded. * @param {string} engineName - Engine name */ async ensureRemoteConfigForEngine(engineName) { const configName = this.getConfigNameForEngine(engineName); if (!configName) { return; } this.remoteConfig = this.remoteConfig || {}; if (this.remoteConfig[configName]) { return; } try { this.remoteConfig[configName] = await ConfigLoader.loadConfig(configName); } catch (e) { if (CONFIG.debugMode) { console.log(`[DarkModer] Failed to load ${configName}, using built-in fallback:`, 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 const theme = this.currentSiteTheme ? { ...baseTheme, ...this.currentSiteTheme } : baseTheme; return HDRToneMapper.apply(theme, this.settings.hdrToneMapping); }, /** * 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); if (this.sessionDisabledFor.has(hostname)) { return false; } // An optional allow-list can restrict dark mode to matching sites. // Exclusions in the list still override positive patterns. const enabledFor = this.settings.enabledFor || []; if (enabledFor.length > 0 && !isURLMatched(location.href, enabledFor)) { return false; } // 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 if (isURLMatched(location.href, allDisabled)) { return false; } // Check if site has built-in dark mode if (isURLMatched(location.href, BUILT_IN_DARK_SITES)) { return false; } if (this.remoteConfig?.darkSites && isURLMatched(location.href, this.remoteConfig.darkSites)) { return false; } return true; }, /** * Apply the dark theme */ async 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(); const engineName = this.getEngineName(theme); const applyToken = {}; this.applyToken = applyToken; await this.ensureRemoteConfigForEngine(engineName); if (this.applyToken !== applyToken) { return; } // 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); const configName = this.getConfigNameForEngine(engineName); const remoteFixes = configName ? this.remoteConfig?.[configName] : null; const remoteFix = ConfigLoader.getSiteFix(hostname, remoteFixes); // Select engine — prefer string `engine` property, fall back to numeric `mode` // Check built-in fixes based on engine type const builtInFixes = (engineName === 'filter' || engineName === 'filterPlus') ? BUILT_IN_INVERSION_FIXES : (engineName === 'dynamic' ? BUILT_IN_DYNAMIC_FIXES : null); const defaultFix = (engineName === 'filter' || engineName === 'filterPlus') ? ConfigLoader.getSiteFix('default', builtInFixes) : null; const builtInFix = builtInFixes ? ConfigLoader.getSiteFix(hostname, builtInFixes) : null; const userFix = ConfigLoader.getSiteFix(hostname, this.settings.siteFixesUser); const siteFix = SiteFixesProcessor.mergeFixes(remoteFix, defaultFix, builtInFix, userFix); switch (engineName) { case 'filter': this.activeEngine = FilterEngine; break; case 'filterPlus': this.activeEngine = FilterPlusEngine; break; case 'static': this.activeEngine = StaticEngine; break; case 'dynamic': default: this.activeEngine = DynamicEngine; break; } // Apply the engine this.activeEngine.apply(theme, siteFix); LightPinManager.update(engineName, theme); this.isApplied = true; if (CONFIG.debugMode) { console.log('[DarkModer] Theme applied with engine:', engineName); } }, /** * Remove the dark theme */ remove() { // Remove active engine if (this.activeEngine) { this.activeEngine.remove(); this.activeEngine = null; } LightPinManager.update(null, 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(hostname = getURLHostname(location.href), disabled = undefined) { if (!hostname) { return false; } this.settings.disabledSites = this.settings.disabledSites || []; this.settings.disabledFor = this.settings.disabledFor || []; const isCurrentlyDisabled = this.settings.disabledSites.includes(hostname) || this.settings.disabledFor.includes(hostname); const shouldDisable = typeof disabled === 'boolean' ? disabled : !isCurrentlyDisabled; if (shouldDisable) { // Add to disabledFor (primary array for UI additions). if (!this.settings.disabledFor.includes(hostname)) { this.settings.disabledFor.push(hostname); } this.remove(); } else { // Remove exact entries from both compatibility arrays. this.settings.disabledSites = this.settings.disabledSites.filter(site => site !== hostname); this.settings.disabledFor = this.settings.disabledFor.filter(site => site !== hostname); if (this.settings.enabled) { this.apply(); } } this.saveSettings(); // Update UI if open if (UI.isOpen) { UI.updateUI(); } return shouldDisable; }, /** * Disable dark mode for the current host until this page session ends. */ disableOnce() { const hostname = getURLHostname(location.href); if (!hostname) { return; } this.sessionDisabledFor.add(hostname); this.remove(); if (UI.isOpen) { UI.updateUI(); } }, /** * Reset all settings to defaults */ resetSettings() { this.scheduledThemeBase = null; 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); }, /** * Normalize DarkModer or Dark Reader exports into DarkModer settings. * @param {Object} imported - Imported JSON * @returns {Object} Normalized settings */ normalizeImportedSettings(imported) { const source = imported.settings && typeof imported.settings === 'object' ? imported.settings : imported; const normalized = deepMerge({ ...DEFAULT_SETTINGS }, source); const sourceTheme = source.theme || source; normalized.theme = deepMerge({ ...DEFAULT_THEME }, source.theme || {}); for (const key of [ 'brightness', 'contrast', 'sepia', 'grayscale', 'darkSchemeBackgroundColor', 'darkSchemeTextColor', 'lightSchemeBackgroundColor', 'lightSchemeTextColor', 'selectionColor', 'fontFamily', 'textStroke' ]) { if (sourceTheme[key] !== undefined) { normalized.theme[key] = sourceTheme[key]; } } if (Array.isArray(source.siteList)) { const disabled = source.applyToListedOnly ? [] : source.siteList; const enabled = source.applyToListedOnly ? source.siteList : []; normalized.disabledFor = [...new Set([...(normalized.disabledFor || []), ...disabled])]; normalized.enabledFor = [...new Set([...(normalized.enabledFor || []), ...enabled])]; } if (Array.isArray(source.customThemes)) { normalized.customThemes = source.customThemes; normalized.siteSettings = normalized.siteSettings || {}; for (const customTheme of source.customThemes) { const urls = customTheme.url || customTheme.urls || []; const theme = customTheme.theme || customTheme; for (const url of urls) { normalized.siteSettings[url] = deepMerge({ ...(normalized.siteSettings[url] || {}) }, theme); } } } return normalized; }, /** * 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 and map compatible Dark Reader exports. this.scheduledThemeBase = null; this.settings = this.normalizeImportedSettings(imported); 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 ''; }, /** * Export the active page's computed color map as CSS variables. * @returns {string} CSS color map */ exportColorMapCSS() { const colors = new Map(); const elements = Array.from(document.body?.querySelectorAll('*') || []).slice(0, 400); const escapeIdent = (value) => { if (window.CSS?.escape) { return CSS.escape(value); } return String(value).replace(/[^a-zA-Z0-9_-]/g, '\\$&'); }; const selectorFor = (el) => { if (el.id) { return `#${escapeIdent(el.id)}`; } const classes = Array.from(el.classList || []).slice(0, 2).map(escapeIdent); return `${el.tagName.toLowerCase()}${classes.length ? `.${classes.join('.')}` : ''}`; }; const addColor = (selector, prop, value) => { if (!value || value === 'rgba(0, 0, 0, 0)' || value === 'transparent') { return; } const key = `${selector}|${prop}|${value}`; colors.set(key, { selector, prop, value }); }; for (const el of elements) { const rect = el.getBoundingClientRect(); if (rect.width <= 0 || rect.height <= 0) { continue; } const style = getComputedStyle(el); const selector = selectorFor(el); addColor(selector, 'color', style.color); addColor(selector, 'background-color', style.backgroundColor); addColor(selector, 'border-color', style.borderColor); addColor(selector, 'fill', style.fill); addColor(selector, 'stroke', style.stroke); } const lines = [ '/* DarkModer computed color map */', ':root {' ]; let index = 1; for (const { value } of colors.values()) { lines.push(` --darkmoder-color-${index++}: ${value};`); } lines.push('}', ''); index = 1; for (const { selector, prop } of colors.values()) { lines.push(`${selector} { ${prop}: var(--darkmoder-color-${index++}); }`); } return lines.join('\n'); }, /** * 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(); } } }; // ============================================================================ // PUBLIC API // ============================================================================ const DarkModerAPI = { setEnabled(enabled) { if (!DarkModer.settings) { return false; } DarkModer.settings.enabled = Boolean(enabled); DarkModer.saveSettings(); if (DarkModer.settings.enabled) { DarkModer.apply(); } else { DarkModer.remove(); } return DarkModer.settings.enabled; }, setEngine(engine) { const supported = ['filter', 'filterPlus', 'dynamic', 'static']; if (!DarkModer.settings || !supported.includes(engine)) { return false; } DarkModer.settings.theme.engine = engine; DarkModer.saveSettings(); if (DarkModer.settings.enabled) { DarkModer.apply(); } return true; }, setThemeOption(name, value) { const numericRanges = { brightness: [50, 150], contrast: [50, 150], sepia: [0, 100], grayscale: [0, 100], textStroke: [0, 10] }; const allowed = new Set([ 'brightness', 'contrast', 'sepia', 'grayscale', 'textStroke', 'darkSchemeBackgroundColor', 'darkSchemeTextColor', 'lightSchemeBackgroundColor', 'lightSchemeTextColor', 'selectionBgColor', 'selectionTextColor', 'linkColor', 'visitedLinkColor', 'borderColor', 'fontFamily', 'useFont' ]); if (!DarkModer.settings || !allowed.has(name)) { return false; } let nextValue = value; if (numericRanges[name]) { const numeric = Number(value); if (!Number.isFinite(numeric)) { return false; } nextValue = clamp(numeric, numericRanges[name][0], numericRanges[name][1]); } DarkModer.settings.theme[name] = nextValue; DarkModer.saveSettings(); if (DarkModer.settings.enabled) { DarkModer.apply(); } return true; }, toggleSite(hostname, disabled) { return DarkModer.toggleSite(hostname, disabled); }, getSettings() { return DarkModer.settings ? JSON.parse(JSON.stringify(DarkModer.settings)) : null; }, resetSettings() { if (!DarkModer.settings) { return false; } DarkModer.resetSettings(); return true; }, openSettings() { UI.open(); }, pinElement(element) { const pinned = LightPinManager.toggle(element); LightPinManager.update(); return pinned; }, compareEngines(firstEngine = 'dynamic', secondEngine = 'filter') { return UI.showEngineComparison(firstEngine, secondEngine); } }; if (typeof window !== 'undefined') { window.DarkReaderUserscript = DarkModerAPI; } // ========================================================================== // 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; } })(); })();