Greasy Fork is available in English.
Restores the Dim (dark blue) background option to X/Twitter display settings.
// ==UserScript==
// @name X Dim Mode
// @namespace https://greasyfork.org/zh-CN/users/1495808-flartiny
// @version 1.2.0
// @description Restores the Dim (dark blue) background option to X/Twitter display settings.
// @author flartiny
// @license MIT
// @match https://x.com/*
// @match https://twitter.com/*
// @match https://pro.x.com/*
// @run-at document-start
// @grant GM_getValue
// @grant GM_setValue
// @grant GM_addValueChangeListener
// @grant GM_registerMenuCommand
// ==/UserScript==
(function initXDimBrowserApi(globalScope) {
const LOCAL_PREFIX = "xdm:";
const fallbackListeners = new Set();
let gmRemoteListenerReady = false;
let localStorageListenerReady = false;
function getExtApi() {
if (typeof browser !== "undefined" && browser.storage) {
return browser;
}
if (typeof chrome !== "undefined" && chrome.storage) {
return chrome;
}
return null;
}
function hasGmStorage() {
return typeof GM_getValue === "function" && typeof GM_setValue === "function";
}
function parseStoredValue(rawValue) {
if (rawValue === null || rawValue === undefined) return undefined;
try {
return JSON.parse(rawValue);
} catch {
return undefined;
}
}
function readLocalValue(key) {
try {
return parseStoredValue(globalScope.localStorage.getItem(LOCAL_PREFIX + key));
} catch {
return undefined;
}
}
function writeLocalValue(key, value) {
try {
if (value === undefined) {
globalScope.localStorage.removeItem(LOCAL_PREFIX + key);
} else {
globalScope.localStorage.setItem(LOCAL_PREFIX + key, JSON.stringify(value));
}
} catch {
// Ignore write failures in restricted storage contexts
}
}
function emitFallbackChange(changes) {
for (const listener of fallbackListeners) {
listener(changes);
}
}
function createGetResult(key, reader) {
if (typeof key === "string") {
return { [key]: reader(key) };
}
if (Array.isArray(key)) {
const result = {};
for (const item of key) result[item] = reader(item);
return result;
}
if (key && typeof key === "object") {
const result = {};
for (const [item, defaultValue] of Object.entries(key)) {
const value = reader(item);
result[item] = value === undefined ? defaultValue : value;
}
return result;
}
return {};
}
function setupGmRemoteListener() {
if (gmRemoteListenerReady) return;
if (typeof GM_addValueChangeListener !== "function") return;
gmRemoteListenerReady = true;
GM_addValueChangeListener("enabled", (key, oldValue, newValue, remote) => {
if (!remote) return;
emitFallbackChange({
[key]: { oldValue, newValue },
});
});
}
function setupLocalStorageListener() {
if (localStorageListenerReady) return;
if (typeof globalScope.addEventListener !== "function") return;
localStorageListenerReady = true;
globalScope.addEventListener("storage", (event) => {
if (!event.key || !event.key.startsWith(LOCAL_PREFIX)) return;
const key = event.key.slice(LOCAL_PREFIX.length);
emitFallbackChange({
[key]: {
oldValue: parseStoredValue(event.oldValue),
newValue: parseStoredValue(event.newValue),
},
});
});
}
function storageGet(key, cb) {
const api = getExtApi();
if (api && typeof browser !== "undefined" && api === browser) {
api.storage.local
.get(key)
.then((result) => cb(result || {}))
.catch(() => cb({}));
return;
}
if (api) {
api.storage.local.get(key, (result) => {
cb(result || {});
});
return;
}
if (hasGmStorage()) {
cb(createGetResult(key, (item) => GM_getValue(item)));
return;
}
cb(createGetResult(key, (item) => readLocalValue(item)));
}
function storageSet(value, cb) {
const api = getExtApi();
if (api && typeof browser !== "undefined" && api === browser) {
api.storage.local
.set(value)
.then(() => {
if (cb) cb();
})
.catch(() => {
if (cb) cb();
});
return;
}
if (api) {
api.storage.local.set(value, () => {
if (cb) cb();
});
return;
}
const changes = {};
for (const [key, newValue] of Object.entries(value || {})) {
const oldValue = hasGmStorage() ? GM_getValue(key) : readLocalValue(key);
if (hasGmStorage()) {
GM_setValue(key, newValue);
} else {
writeLocalValue(key, newValue);
}
changes[key] = { oldValue, newValue };
}
if (Object.keys(changes).length) {
emitFallbackChange(changes);
}
if (cb) cb();
}
function onStorageChanged(handler) {
const api = getExtApi();
if (api) {
api.storage.onChanged.addListener((changes, areaName) => {
if (areaName && areaName !== "local") return;
handler(changes);
});
return;
}
fallbackListeners.add(handler);
if (hasGmStorage()) {
setupGmRemoteListener();
return;
}
setupLocalStorageListener();
}
function registerToggleMenuCommands() {
if (typeof GM_registerMenuCommand !== "function") return;
GM_registerMenuCommand("X Dim Mode: Enable", () => {
storageSet({ enabled: true });
});
GM_registerMenuCommand("X Dim Mode: Disable", () => {
storageSet({ enabled: false });
});
}
globalScope.XDimBrowserApi = {
getExtApi,
storageGet,
storageSet,
onStorageChanged,
registerToggleMenuCommands,
};
})(globalThis);
const DIM_BASE_ID = "x-dim-base-ext";
const DIM_BTN_ID = "x-dim-option-btn";
const DIM_CLASS = "x-dim-active";
const { storageGet, storageSet, onStorageChanged } = globalThis.XDimBrowserApi;
// ── Dim Theme CSS ──────────────────────────────────────────────────
const BASE_CSS = `
/* Dim theme variables */
html.${DIM_CLASS} {
--xdm-bg: rgb(21, 32, 43);
--xdm-bg-hover: rgb(30, 39, 50);
--xdm-bg-elevated: rgb(39, 51, 64);
--xdm-backdrop: rgba(21, 32, 43, .85);
--xdm-text: rgb(139, 152, 165);
--xdm-border: rgb(56, 68, 77);
}
/* Override X's own Lights Out theme variables */
html.${DIM_CLASS} body.LightsOut {
--color: var(--xdm-text);
--border: 206 16% 26%;
--input: 206 16% 26%;
--border-color: var(--xdm-border);
}
/* Chat / DM interface (Tailwind + shadcn/Radix) */
html.${DIM_CLASS}[data-theme="dark"],
html.${DIM_CLASS} [data-theme="dark"] {
--background: 215 29% 13%;
--border: 206 16% 26%;
--input: 206 16% 26%;
--muted-foreground: 206 16% 55%;
--color-background: 215 29% 13%;
--color-gray-0: 215 29% 13%;
--color-gray-50: 206 16% 26%;
--color-gray-100: 206 16% 26%;
--color-gray-700: 206 16% 60%;
--color-gray-800: 206 16% 50%;
}
/* ── Black background overrides ── */
/* Body — catches class-based black bg (e.g. Creator Studio) */
html.${DIM_CLASS} body {
background-color: var(--xdm-bg) !important;
}
/* Inline styles (covers body, divs, modals, dropdowns, etc.) */
html.${DIM_CLASS} [style*="background-color: rgb(0, 0, 0)"],
html.${DIM_CLASS} [style*="background-color: rgba(0, 0, 0, 1)"] {
background-color: var(--xdm-bg) !important;
}
/* Elevated section cards (rgb(24,24,27) in dark mode → slightly lighter in dim) */
html.${DIM_CLASS} [style*="background-color: rgb(24, 24, 27)"] {
background-color: var(--xdm-bg-hover) !important;
}
/* Icon containers in menu rows (Premium, etc.) */
html.${DIM_CLASS} [role="link"] > div > div:first-child div:has(> svg:only-child) {
background-color: var(--xdm-bg-elevated) !important;
}
/* X utility classes for black backgrounds */
html.${DIM_CLASS} .r-kemksi,
html.${DIM_CLASS} .r-1niwhzg,
html.${DIM_CLASS} .r-yfoy6g,
html.${DIM_CLASS} .r-14lw9ot {
background-color: var(--xdm-bg) !important;
}
/* Action-button hover circles — make transparent so they match any parent bg */
html.${DIM_CLASS} .r-1niwhzg.r-sdzlij {
background-color: transparent !important;
}
/* Timeline top bar */
html.${DIM_CLASS} .r-5zmot {
background-color: var(--xdm-backdrop) !important;
}
/* Tweet character counter separator */
html.${DIM_CLASS} .r-1shrkeu {
background-color: var(--xdm-border) !important;
}
/* Sidebar button hover */
html.${DIM_CLASS} .r-1hdo0pc {
background-color: var(--xdm-bg-hover) !important;
}
/* Secondary background (section cards on Premium, etc.) */
html.${DIM_CLASS} .r-g2wdr4 {
background-color: var(--xdm-bg-hover) !important;
}
html.${DIM_CLASS} .r-g2wdr4 [role="link"]:hover {
background-color: var(--xdm-bg-elevated) !important;
}
/* Borders */
html.${DIM_CLASS} .r-1kqtdi0,
html.${DIM_CLASS} .r-1roi411 {
border-color: var(--xdm-border) !important;
}
html.${DIM_CLASS} .r-2sztyj {
border-top-color: var(--xdm-border) !important;
}
html.${DIM_CLASS} .r-1igl3o0,
html.${DIM_CLASS} .r-rull8r {
border-bottom-color: var(--xdm-border) !important;
}
/* Separators / dividers */
html.${DIM_CLASS} .r-gu4em3,
html.${DIM_CLASS} .r-1bnu78o {
background-color: var(--xdm-border) !important;
}
/* Search bar icon, tweet character counter */
html.${DIM_CLASS} .r-1bwzh9t {
color: var(--xdm-text) !important;
}
/* "What's happening" text */
html.${DIM_CLASS} .draftjs-styles_0 .public-DraftEditorPlaceholder-root,
html.${DIM_CLASS} .public-DraftEditorPlaceholder-inner {
color: var(--xdm-text) !important;
}
/* Secondary text */
html.${DIM_CLASS} [style*="color: rgb(113, 118, 123)"],
html.${DIM_CLASS} [style*="-webkit-line-clamp: 3; color: rgb(113, 118, 123)"],
html.${DIM_CLASS} [style*="-webkit-line-clamp: 2; color: rgb(113, 118, 123)"] {
color: var(--xdm-text) !important;
}
/* Placeholders */
html.${DIM_CLASS} ::placeholder {
color: var(--xdm-text) !important;
}
/* Tailwind classes used in chat/DM interface */
html.${DIM_CLASS} .bg-gray-0 {
background-color: var(--xdm-bg) !important;
}
html.${DIM_CLASS} .border-gray-50,
html.${DIM_CLASS} .border-gray-100 {
border-color: var(--xdm-border) !important;
}
/* Grok buttons (active) */
html.${DIM_CLASS} [style*="border-color: rgb(47, 51, 54)"].r-1che71a {
background-color: var(--xdm-bg-hover) !important;
}
/* Scanner-discovered black backgrounds */
html.${DIM_CLASS} .xdm-dimmed {
background-color: var(--xdm-bg) !important;
}
/* Scanner-discovered elevated backgrounds (e.g. section cards) */
html.${DIM_CLASS} .xdm-dimmed-elevated {
background-color: var(--xdm-bg-hover) !important;
}
/* Creator Studio icon containers (jf-element framework) */
html.${DIM_CLASS} .jf-element:has(> span:only-child > svg:only-child) {
background-color: var(--xdm-bg-elevated) !important;
}
/* Creator Studio dividers inside elevated section cards */
html.${DIM_CLASS} .xdm-dimmed-elevated .jf-element:empty {
background-color: var(--xdm-border) !important;
border-color: var(--xdm-border) !important;
}
/* Scrollbar */
html.${DIM_CLASS} {
scrollbar-color: var(--xdm-border) var(--xdm-bg);
}
`;
// Always update the style element — prevents stale CSS after extension reload
function ensureBaseCSS() {
let style = document.getElementById(DIM_BASE_ID);
if (!style) {
style = document.createElement("style");
style.id = DIM_BASE_ID;
(document.head || document.documentElement).appendChild(style);
}
if (style.textContent !== BASE_CSS) style.textContent = BASE_CSS;
}
// Inject CSS immediately at document_start — don't wait for async storage read.
// Rules are gated by html.x-dim-active so they're inert until the class is added.
ensureBaseCSS();
function applyDim() {
ensureBaseCSS();
document.documentElement.classList.add(DIM_CLASS);
if (document.body) queueScan([document.body]);
}
function removeDim() {
document.documentElement.classList.remove(DIM_CLASS);
// Cancel any pending scan
if (_scanFrame) {
cancelAnimationFrame(_scanFrame);
_scanFrame = 0;
_pending.clear();
}
// Remove scanner-applied classes (non-destructive — doesn't touch original styles)
for (const el of document.querySelectorAll(".xdm-dimmed, .xdm-dimmed-elevated")) {
el.classList.remove("xdm-dimmed", "xdm-dimmed-elevated");
}
}
// ── System Theme Sync ─────────────────────────────────────────────
// Follows OS preference: dark → Dim, light → Default.
// Watches body.LightsOut (X's dark mode class) to detect theme state.
let _bodyObserver;
let _suspendedForLight = false;
function syncDimWithTheme() {
if (!_enabled || !document.body) return;
const hasLightsOut = document.body.classList.contains("LightsOut");
const dimActive = document.documentElement.classList.contains(DIM_CLASS);
if (hasLightsOut) {
// X is in dark mode → activate dim
_suspendedForLight = false;
if (!dimActive) {
applyDim();
for (const ms of [500, 1500, 3000, 5000]) setTimeout(fullRescan, ms);
}
} else if (dimActive && _seenLightsOut) {
// X switched to light mode (LightsOut was present, now removed) → suspend dim
_suspendedForLight = true;
removeDim();
}
}
// Track whether X has ever been in dark mode this session.
// Prevents removing dim before X has finished initializing.
let _seenLightsOut = false;
function startBodyObserver() {
if (_bodyObserver || !document.body) return;
if (document.body.classList.contains("LightsOut")) _seenLightsOut = true;
_bodyObserver = new MutationObserver(() => {
if (document.body.classList.contains("LightsOut")) _seenLightsOut = true;
syncDimWithTheme();
});
_bodyObserver.observe(document.body, {
attributes: true,
attributeFilter: ["class"],
});
}
function stopBodyObserver() {
if (_bodyObserver) {
_bodyObserver.disconnect();
_bodyObserver = null;
}
}
// ── Black Background Scanner ─────────────────────────────────────
// Catches inline black backgrounds not covered by known CSS selectors.
// Uses a CSS class (not inline styles) so toggling is instant and non-destructive.
let _scanFrame = 0;
const _pending = new Set();
function queueScan(nodes) {
for (const n of nodes) {
if (n && n.nodeType === 1) _pending.add(n);
}
if (_pending.size && !_scanFrame) {
_scanFrame = requestAnimationFrame(flushScan);
}
}
function flushScan() {
_scanFrame = 0;
if (!document.documentElement.classList.contains(DIM_CLASS)) {
_pending.clear();
return;
}
const batch = [..._pending];
_pending.clear();
for (const node of batch) dimSubtree(node);
}
function dimSubtree(root) {
dimElement(root);
for (const el of root.querySelectorAll("div,main,aside,header,nav,section,article,footer,button")) {
dimElement(el);
}
}
function dimElement(el) {
if (!el || el.nodeType !== 1 || el.classList.contains("xdm-dimmed") || el.classList.contains("xdm-dimmed-elevated")) return;
const bg = el.classList.contains("jf-element")
? (() => { try { return getComputedStyle(el).backgroundColor; } catch { return ""; } })()
: el.style.backgroundColor;
if (bg === "rgb(0, 0, 0)" || bg === "rgba(0, 0, 0, 1)") {
el.classList.add("xdm-dimmed");
} else if (bg === "rgb(24, 24, 27)") {
el.classList.add("xdm-dimmed-elevated");
}
}
// ── Display Settings Injection ─────────────────────────────────────
const CHECKMARK_SVG = `<svg viewBox="0 0 24 24" aria-hidden="true" class="r-4qtqp9 r-yyyyoo r-dnmrzs r-bnwqim r-lrvibr r-m6rgpd r-jwli3a r-1hjwoze r-12ym1je"><g><path d="M9.64 18.952l-5.55-4.861 1.317-1.504 3.951 3.459 8.459-10.948L19.4 6.32 9.64 18.952z"></path></g></svg>`;
function setSelected(btnEl) {
btnEl.style.borderColor = "rgb(29, 155, 240)";
btnEl.style.borderWidth = "2px";
const circle = btnEl.querySelector('[role="radio"] > div');
if (circle) {
circle.style.backgroundColor = "rgb(29, 155, 240)";
circle.style.borderColor = "rgb(29, 155, 240)";
circle.innerHTML = CHECKMARK_SVG;
}
const input = btnEl.querySelector('input[type="radio"]');
if (input) input.checked = true;
}
function setUnselected(btnEl) {
btnEl.style.borderColor = "rgb(51, 54, 57)";
btnEl.style.borderWidth = "1px";
const circle = btnEl.querySelector('[role="radio"] > div');
if (circle) {
circle.style.backgroundColor = "rgba(0, 0, 0, 0)";
circle.style.borderColor = "rgb(185, 202, 211)";
circle.innerHTML = "";
}
const input = btnEl.querySelector('input[type="radio"]');
if (input) input.checked = false;
}
function tryInjectDimOption() {
if (document.getElementById(DIM_BTN_ID)) return;
// Find the background picker by its radio inputs (language-independent)
const bgRadio = document.querySelector('input[name="background-picker"]');
if (!bgRadio) return;
const radiogroup = bgRadio.closest('[role="radiogroup"]');
if (!radiogroup) return;
const buttons = radiogroup.querySelectorAll(':scope > div');
if (buttons.length < 2) return;
const defaultBtn = buttons[0];
const lightsOutBtn = buttons[1];
// Clone the Lights Out button as our base
const dimBtn = lightsOutBtn.cloneNode(true);
dimBtn.id = DIM_BTN_ID;
// Set dim background color
dimBtn.style.backgroundColor = "rgb(21, 32, 43)";
// Change label to "Dim"
const label = dimBtn.querySelector("span");
if (label) label.textContent = "Dim";
// Update radio input
const input = dimBtn.querySelector('input[type="radio"]');
if (input) {
input.setAttribute("aria-label", "Dim");
input.checked = false;
}
// Insert between Default and Lights Out
radiogroup.insertBefore(dimBtn, lightsOutBtn);
// Set initial visual state based on whether dim is enabled
storageGet("enabled", ({ enabled }) => {
syncSettingsButtons(!!enabled);
});
// ── Click handlers ──
dimBtn.addEventListener("click", () => {
storageSet({ enabled: true });
syncSettingsButtons(true);
activateLightsOut();
});
// When Default or Lights Out is clicked directly, disable Dim
for (const nativeBtn of [defaultBtn, lightsOutBtn]) {
nativeBtn.addEventListener("click", () => {
if (_switchingToDim) return; // Ignore clicks triggered by dim switch
storageSet({ enabled: false });
setUnselected(dimBtn);
});
}
}
// ── Lights Out Helper ──────────────────────────────────────────────
// Clicks X's Lights Out radio (if the Display settings page is open) to ensure
// the correct base theme. Used by both the Dim button and the popup toggle.
let _switchingToDim = false;
function activateLightsOut() {
const dimBtn = document.getElementById(DIM_BTN_ID);
if (!dimBtn) return; // Settings page not open
const radiogroup = dimBtn.closest('[role="radiogroup"]');
if (!radiogroup) return;
const allBtns = radiogroup.querySelectorAll(":scope > div");
const lightsOutBtn = allBtns[allBtns.length - 1];
if (!lightsOutBtn) return;
const loInput = lightsOutBtn.querySelector('input[type="radio"]');
if (loInput && !loInput.checked) {
_switchingToDim = true;
loInput.click();
loInput.dispatchEvent(new Event("input", { bubbles: true }));
loInput.dispatchEvent(new Event("change", { bubbles: true }));
setTimeout(() => { _switchingToDim = false; }, 300);
}
}
// ── Observer & Init ────────────────────────────────────────────────
let _enabled = false;
let observer;
function startObserver() {
if (observer) return;
observer = new MutationObserver((mutations) => {
try {
// Re-apply dim if class was removed by X (unless suspended for light mode)
if (_enabled && !_suspendedForLight && !document.documentElement.classList.contains(DIM_CLASS)) {
applyDim();
}
// Scan newly added nodes for black backgrounds
if (_enabled && document.documentElement.classList.contains(DIM_CLASS)) {
for (const m of mutations) {
if (m.addedNodes.length) queueScan(m.addedNodes);
}
}
// Try to inject the Dim button on the display settings page
tryInjectDimOption();
// Start body observer once body is available
if (_enabled && document.body && !_bodyObserver) {
startBodyObserver();
}
} catch {
// Extension context invalidated after reload — clean up
observer.disconnect();
}
});
observer.observe(document.documentElement, {
childList: true,
subtree: true,
});
}
// Re-scan the entire body to catch elements the initial scan or observer missed
function fullRescan() {
if (_enabled && document.body) queueScan([document.body]);
}
// Init — single storage read, then use cached state
storageGet("enabled", ({ enabled }) => {
if (enabled === undefined) {
_enabled = true;
storageSet({ enabled: true });
} else {
_enabled = !!enabled;
}
if (_enabled) {
// Apply dim immediately if system is dark (avoids flash of black).
// If system is light, body observer will handle it once X sets its theme.
const systemDark = !window.matchMedia || window.matchMedia("(prefers-color-scheme: dark)").matches;
if (systemDark) {
applyDim();
for (const ms of [500, 1500, 3000, 5000]) setTimeout(fullRescan, ms);
}
}
startObserver();
tryInjectDimOption();
// Start body observer if body is already available
if (_enabled && document.body) {
startBodyObserver();
}
});
// Sync the radio buttons on the Display settings page with the current state
function syncSettingsButtons(enabled) {
const dimBtn = document.getElementById(DIM_BTN_ID);
if (!dimBtn) return;
const radiogroup = dimBtn.closest('[role="radiogroup"]');
if (!radiogroup) return;
const allBtns = radiogroup.querySelectorAll(":scope > div");
const lightsOutBtn = allBtns[allBtns.length - 1];
if (enabled) {
setSelected(dimBtn);
for (const btn of allBtns) {
if (btn !== dimBtn) setUnselected(btn);
}
} else {
setUnselected(dimBtn);
if (lightsOutBtn) setSelected(lightsOutBtn);
}
}
// Listen for toggle — updates cached state synchronously
onStorageChanged((changes) => {
if (changes.enabled) {
_enabled = !!changes.enabled.newValue;
if (_enabled) {
_suspendedForLight = false;
startBodyObserver();
applyDim();
activateLightsOut();
} else {
stopBodyObserver();
removeDim();
}
syncSettingsButtons(_enabled);
}
});
(function initTampermonkeyMenu() {
if (!globalThis.XDimBrowserApi) return;
globalThis.XDimBrowserApi.registerToggleMenuCommands();
})();