Last active
March 10, 2026 11:34
-
-
Save franzalex/63af4ded297cc6538ae3b30a453a1baa to your computer and use it in GitHub Desktop.
Dim Background Toggle (Night Mode) – Userscript
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| // ==UserScript== | |
| // @name Dim Background Toggle (Night Mode) | |
| // @namespace https://github.com/franzalex/ | |
| // @description Automatically dims backgrounds at night with an Always Dim mode. Restores original colors when disabled. | |
| // @author franzalex | |
| // @version 1.10.5 | |
| // @match *://*/* | |
| // @grant GM_registerMenuCommand | |
| // @grant GM_unregisterMenuCommand | |
| // @grant GM_setValue | |
| // @grant GM_getValue | |
| // @icon https://raw.githubusercontent.com/twitter/twemoji/master/assets/72x72/1f31a.png | |
| // @updateURL https://gist.githubusercontent.com/franzalex/63af4ded297cc6538ae3b30a453a1baa/raw/franzalex.background-dimmer.user.js | |
| // @downloadURL https://gist.githubusercontent.com/franzalex/63af4ded297cc6538ae3b30a453a1baa/raw/franzalex.background-dimmer.user.js | |
| // ==/UserScript== | |
| (function() { | |
| 'use strict'; | |
| let enabled = GM_getValue("enabled", true); // scheduled mode | |
| let alwaysDark = GM_getValue("alwaysDark", false); // persistent override | |
| let isCurrentlyDimmed = false; // track dimmed status of the page | |
| let menuIdAlways, menuIdToggle; | |
| const TOAST_MESSAGES = { | |
| alwaysOnEnabled: "Always Dim ON<br>(Ctrl+Shift+Alt+D)", | |
| alwaysOnDisabled: "Always Dim OFF<br>(Ctrl+Shift+Alt+D)", | |
| autoDimEnabled: "Auto Dim ON<br>(Ctrl+Alt+D)", | |
| autoDimDisabled: "Auto Dim OFF<br>(Ctrl+Alt+D)" | |
| }; | |
| // storage for original colors | |
| let originalBodyColor = null; | |
| const originalElementColors = new Map(); // Map is iterable; WeakMap is not | |
| // --- Time functions --- | |
| function isNightTime() { | |
| const now = new Date(); | |
| const hour = now.getHours(); | |
| return (hour >= 22 || hour < 6); | |
| } | |
| function scheduleNextAutoSwitch() { | |
| const now = new Date(); | |
| const next = new Date(now); | |
| // If current time is daytime (6 < hour < 22), schedule for 22:00 today | |
| if (now.getHours() >= 6 && now.getHours() < 22) { | |
| next.setHours(22, 0, 0, 0); | |
| } else { | |
| // Otherwise, schedule for 06:00 next day | |
| next.setDate(next.getDate() + (now.getHours() >= 22 ? 1 : 0)); | |
| next.setHours(6, 0, 0, 0); | |
| } | |
| const delay = next.getTime() - now.getTime(); | |
| setTimeout(() => { | |
| refreshState(); // refresh state | |
| debounceAdjustBackground(); // run dim/restore logic | |
| scheduleNextAutoSwitch(); // reschedule the next boundary | |
| }, delay); | |
| } | |
| // --- RGB & HSV functions --- | |
| function parseRgb(bgColor) { | |
| const match = bgColor.match(/rgb\((\d+),\s*(\d+),\s*(\d+)\)/); | |
| if (match) { | |
| return { | |
| r: parseInt(match[1], 10), | |
| g: parseInt(match[2], 10), | |
| b: parseInt(match[3], 10) | |
| }; | |
| } | |
| return null; | |
| } | |
| function rgbToHsv(r, g, b) { | |
| r /= 255; | |
| g /= 255; | |
| b /= 255; | |
| const max = Math.max(r, g, b), min = Math.min(r, g, b); | |
| const d = max - min; | |
| let h, s, v = max; | |
| if (d === 0) { | |
| h = 0; | |
| } else if (max === r) { | |
| h = ((g - b) / d) % 6; | |
| } else if (max === g) { | |
| h = (b - r) / d + 2; | |
| } else { | |
| h = (r - g) / d + 4; | |
| } | |
| h = Math.round(h * 60); | |
| if (h < 0) h += 360; | |
| s = max === 0 ? 0 : d / max; | |
| return { h: h, s: s * 100, v: v * 100 }; | |
| } | |
| function hsvToRgb(h, s, v) { | |
| s /= 100; | |
| v /= 100; | |
| const c = v * s; | |
| const x = c * (1 - Math.abs((h / 60) % 2 - 1)); | |
| const m = v - c; | |
| let r, g, b; | |
| if (h < 60) { r = c; g = x; b = 0; } | |
| else if (h < 120) { r = x; g = c; b = 0; } | |
| else if (h < 180) { r = 0; g = c; b = x; } | |
| else if (h < 240) { r = 0; g = x; b = c; } | |
| else if (h < 300) { r = x; g = 0; b = c; } | |
| else { r = c; g = 0; b = x; } | |
| r = Math.round((r + m) * 255); | |
| g = Math.round((g + m) * 255); | |
| b = Math.round((b + m) * 255); | |
| return `rgb(${r}, ${g}, ${b})`; | |
| } | |
| // --- element adjustment functions --- | |
| function adjustElementBackgrounds() { | |
| const elements = document.querySelectorAll("div, section, article, main, aside, header"); | |
| elements.forEach(el => { | |
| const style = window.getComputedStyle(el); | |
| const bgColor = style.backgroundColor; | |
| // Skip transparent backgrounds | |
| if (bgColor === "transparent" || (bgColor.startsWith("rgba") && bgColor.endsWith(", 0)"))) return; | |
| // Avoid reprocessing already dimmed elements | |
| if (el.dataset.dimmed === "true") return; | |
| const rgb = parseRgb(bgColor); | |
| if (rgb) { | |
| const hsv = rgbToHsv(rgb.r, rgb.g, rgb.b); | |
| if (hsv.v > 75) { | |
| hsv.v = 75; | |
| const newColor = hsvToRgb(hsv.h, hsv.s, hsv.v); | |
| // Save original element color once | |
| if (!originalElementColors.has(el)) { | |
| originalElementColors.set(el, el.style.backgroundColor || bgColor); | |
| } | |
| el.style.backgroundColor = newColor; | |
| el.dataset.dimmed = "true"; | |
| } | |
| } | |
| }); | |
| } | |
| function adjustBackground() { | |
| // break early if toggle is off and not in alwaysDark mode | |
| if (!enabled && !alwaysDark) return; | |
| if (isNightTime() || alwaysDark) { | |
| const body = document.body; | |
| const style = window.getComputedStyle(body); | |
| const bgColor = style.backgroundColor; | |
| const rgb = parseRgb(bgColor); | |
| if (rgb) { | |
| const hsv = rgbToHsv(rgb.r, rgb.g, rgb.b); | |
| if (hsv.v > 75) { | |
| hsv.v = 75; | |
| const newColor = hsvToRgb(hsv.h, hsv.s, hsv.v); | |
| // Save original body color once | |
| if (originalBodyColor === null) { | |
| originalBodyColor = body.style.backgroundColor || bgColor; | |
| } | |
| body.style.backgroundColor = newColor; | |
| body.dataset.dimmed = "true"; | |
| adjustElementBackgrounds(); | |
| isCurrentlyDimmed = true; // dimming process completed | |
| } | |
| } | |
| } else { | |
| // daytime and not alwaysDark → restore if currently dimmed | |
| if (isCurrentlyDimmed) { | |
| restoreOriginalColors(); | |
| isCurrentlyDimmed = false; // no longer dimmed | |
| } | |
| } | |
| // manage observer state | |
| manageObserver(); | |
| } | |
| function restoreOriginalColors() { | |
| // Restore body | |
| if (originalBodyColor !== null) { | |
| document.body.style.backgroundColor = originalBodyColor; | |
| originalBodyColor = null; | |
| } | |
| document.body.dataset.dimmed = "false"; | |
| // Restore elements | |
| originalElementColors.forEach((color, el) => { | |
| // Element may have been removed from DOM; guard access | |
| if (el && el.style) { | |
| el.style.backgroundColor = color; | |
| el.dataset.dimmed = "false"; | |
| } | |
| }); | |
| originalElementColors.clear(); | |
| } | |
| // --- menu & interaction --- | |
| function showToast(message) { | |
| const toast = document.createElement("div"); | |
| toast.innerHTML = message; | |
| toast.style.position = "fixed"; | |
| toast.style.bottom = "20px"; | |
| toast.style.left = "50%"; | |
| toast.style.transform = "translateX(-50%)"; | |
| toast.style.background = "rgba(40, 40, 40, 0.8)"; | |
| toast.style.color = "#fff"; | |
| toast.style.padding = "10px 20px"; | |
| toast.style.borderRadius = "5px"; | |
| toast.style.fontSize = "14px"; | |
| toast.style.zIndex = "9999"; | |
| toast.style.opacity = "0"; | |
| toast.style.transition = "opacity 0.5s ease"; | |
| toast.style.textAlign = "center"; | |
| document.body.appendChild(toast); | |
| requestAnimationFrame(() => { | |
| toast.style.opacity = "1"; | |
| }); | |
| setTimeout(() => { | |
| toast.style.opacity = "0"; | |
| setTimeout(() => toast.remove(), 500); | |
| }, 2500); | |
| } | |
| function updateMenus() { | |
| if (menuIdAlways) GM_unregisterMenuCommand(menuIdAlways); | |
| if (menuIdToggle) GM_unregisterMenuCommand(menuIdToggle); | |
| const labelAlways = alwaysDark | |
| ? "Always Dim: ON (click to disable)" | |
| : "Always Dim: OFF (click to enable)"; | |
| menuIdAlways = GM_registerMenuCommand(labelAlways, () => { | |
| alwaysDark = !alwaysDark; | |
| GM_setValue("alwaysDark", alwaysDark); | |
| refreshState(); | |
| if (alwaysDark) { | |
| adjustBackground(); | |
| showToast(TOAST_MESSAGES.alwaysOnEnabled); | |
| } else { | |
| restoreOriginalColors(); | |
| showToast(TOAST_MESSAGES.alwaysOnDisabled); | |
| } | |
| updateMenus(); | |
| }); | |
| const labelToggle = enabled | |
| ? "Auto Dim: ON (click to disable)" | |
| : "Auto Dim: OFF (click to enable)"; | |
| menuIdToggle = GM_registerMenuCommand(labelToggle, () => { | |
| enabled = !enabled; | |
| GM_setValue("enabled", enabled); // persist state | |
| refreshState(); | |
| if (enabled) { | |
| adjustBackground(); | |
| showToast(TOAST_MESSAGES.autoDimEnabled); | |
| } else { | |
| restoreOriginalColors(); | |
| showToast(TOAST_MESSAGES.autoDimDisabled); | |
| } | |
| updateMenus(); | |
| }); | |
| } | |
| function refreshState() { | |
| /* refreshes the state of the variables to match persistence; | |
| * useful if state was changed while on a different tab, or the state was | |
| * changed between the two scheduled checkpoints. | |
| */ | |
| enabled = GM_getValue("enabled", true); | |
| alwaysDark = GM_getValue("alwaysDark", false); | |
| } | |
| // --- rate limiters --- | |
| function debounceRateLimiter(fn, delay) { | |
| // debounce: Wait until changes stop for a period, then run | |
| let timeoutId; | |
| return function(...args) { | |
| clearTimeout(timeoutId); | |
| timeoutId = setTimeout(() => fn.apply(this, args), delay); | |
| }; | |
| } | |
| function throttleRateLimiter(fn, limit) { | |
| // throttle: Run only once in the time limit specified | |
| let inThrottle = false; | |
| return function(...args) { | |
| if (!inThrottle) { | |
| fn.apply(this, args); | |
| inThrottle = true; | |
| setTimeout(() => inThrottle = false, limit); | |
| } | |
| }; | |
| } | |
| function hybridRateLimiter(fn, debounceDelay = 250, throttleLimit = 750) { | |
| // --- Hybrid: two debounces then throttle --- | |
| const debounced = debounceRateLimiter(fn, debounceDelay); | |
| const throttled = throttleRateLimiter(fn, throttleLimit); | |
| let debounceRuns = 0; | |
| let useThrottle = false; | |
| return function(...args) { | |
| if (!useThrottle) { | |
| debounced.apply(this, args); | |
| debounceRuns++; | |
| if (debounceRuns >= 2) { | |
| useThrottle = true; | |
| } | |
| } else { | |
| throttled.apply(this, args); | |
| } | |
| }; | |
| } | |
| function manageObserver() { | |
| if (!isNightTime() && !alwaysDark) { | |
| // daytime: no need to observe DOM for changes | |
| observer.disconnect(); | |
| } else { | |
| // night or alwaysDark: ensure observer is active | |
| observer.observe(document.body, {attributes: true, childList: true, subtree: true}); | |
| } | |
| } | |
| // debounced events: debounces duplicate triggers that happen in a short space of time | |
| const debounceAdjustBackground = debounceRateLimiter(adjustBackground, 200); | |
| // Observe DOM changes with hybrid approach | |
| const rateLimitedObserver = hybridRateLimiter(adjustBackground, 250, 750); | |
| const observer = new MutationObserver(() => { | |
| rateLimitedObserver(); | |
| }); | |
| observer.observe(document.body, { attributes: true, childList: true, subtree: true }); | |
| // --- initial run --- | |
| refreshState(); | |
| adjustBackground(); | |
| scheduleNextAutoSwitch(); | |
| updateMenus(); | |
| // --- event listeners --- | |
| document.addEventListener("keydown", (e) => { | |
| if (e.ctrlKey && e.altKey && !e.shiftKey && e.key.toLowerCase() === "d") { | |
| enabled = !enabled; | |
| GM_setValue("enabled", enabled); | |
| refreshState(); | |
| if (enabled) { | |
| adjustBackground(); | |
| showToast(TOAST_MESSAGES.autoDimEnabled); | |
| } else { | |
| restoreOriginalColors(); | |
| showToast(TOAST_MESSAGES.autoDimDisabled); | |
| } | |
| } | |
| }); | |
| document.addEventListener("keydown", (e) => { | |
| if (e.ctrlKey && e.altKey && e.shiftKey && e.key.toLowerCase() === "d") { | |
| alwaysDark = !alwaysDark; | |
| GM_setValue("alwaysDark", alwaysDark); | |
| refreshState(); | |
| if (alwaysDark) { | |
| adjustBackground(); | |
| showToast(TOAST_MESSAGES.alwaysOnEnabled); | |
| } else { | |
| restoreOriginalColors(); | |
| showToast(TOAST_MESSAGES.alwaysOnDisabled); | |
| } | |
| updateMenus(); | |
| } | |
| }); | |
| document.addEventListener("visibilitychange", () => { | |
| // rerun auto-dark when tab becomes active again | |
| if (!document.hidden) { | |
| refreshState(); | |
| debounceAdjustBackground(); | |
| } | |
| }); | |
| window.addEventListener("focus", () => { | |
| // rerun auto-dark when window regains focus (after minimize/restore) | |
| refreshState(); | |
| debounceAdjustBackground(); | |
| }); | |
| })(); |
Author
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Dim Background Toggle (Night Mode) – Userscript
A lightweight userscript that dims bright backgrounds at night or on demand.
Features include:
Ctrl+Alt+D→ Toggle Auto DimCtrl+Shift+Alt+D→ Toggle Always Dim📖 Usage
Ctrl+Alt+D→ Enable/disable Auto Dim schedulingCtrl+Shift+Alt+D→ Enable/disable Always Dim mode📝 Changelog
v1.10.5
@updateURLand@downloadURLmetadata for seamless auto‑updates via Tampermonkeyv1.10.4 – First public release