// ==UserScript== // @name Clean Theme Switcher // @namespace local.clean-theme-switcher // @version 0.4.0 // @description Force readable gray/light/black themes on ordinary web pages. // @author local // @match http://*/* // @match https://*/* // @match file:///* // @match file://*/* // @run-at document-start // @grant none // ==/UserScript== (() => { "use strict"; const STORAGE_KEY = "__clean_theme_switcher_v2__"; const PANEL_ID = "clean-theme-switcher-host"; const ADAPTED_SURFACE_CLASS = "clean-theme-switcher-surface"; const ADAPTED_DARK_SURFACE_CLASS = "clean-theme-switcher-dark-surface"; const ADAPTED_CONTROL_CLASS = "clean-theme-switcher-control"; const STYLE_ID = "clean-theme-switcher-style"; const BOOT_STYLE_ID = "clean-theme-switcher-boot-style"; const DEFAULT_PRESET = "gray"; const FORCE_GRAY_ON_PAGE_OPEN = true; const DEBUG = false; const LOG_PREFIX = "[CleanThemeSwitcher]"; let themeEnabled = true; const PRESETS = { nativeDark: { name: "Native Dark", mode: "native", colorScheme: "dark", bg: "#202124", surface: "#202124", elevated: "#303134", code: "#202124", text: "#e8eaed", muted: "#bdc1c6", border: "#5f6368", link: "#8ab4f8", }, gray: { name: "Gray", colorScheme: "dark", bg: "#242629", surface: "#1f2124", elevated: "#2b2e32", code: "#191b1f", text: "#e7e9ed", muted: "#aeb4bd", border: "#3f454d", link: "#7fb0ff", }, light: { name: "Light", colorScheme: "light", bg: "#ffffff", surface: "#f3f3f3", elevated: "#ffffff", code: "#f6f8fa", text: "#1f1f1f", muted: "#616161", border: "#d0d7de", link: "#0969da", }, black: { name: "Black", colorScheme: "dark", bg: "#000000", surface: "#111111", elevated: "#181818", code: "#181818", text: "#f5f5f5", muted: "#b5b5b5", border: "#3a3a3a", link: "#4daafc", }, dracula: { name: "Dracula", colorScheme: "dark", bg: "#282a36", surface: "#343746", elevated: "#44475a", code: "#21222c", text: "#f8f8f2", muted: "#bfbfc7", border: "#6272a4", link: "#8be9fd", }, oneDark: { name: "One Dark", colorScheme: "dark", bg: "#282c34", surface: "#21252b", elevated: "#2c313c", code: "#21252b", text: "#abb2bf", muted: "#828997", border: "#3e4451", link: "#61afef", }, nord: { name: "Nord", colorScheme: "dark", bg: "#2e3440", surface: "#3b4252", elevated: "#434c5e", code: "#242933", text: "#eceff4", muted: "#d8dee9", border: "#4c566a", link: "#88c0d0", }, tokyoNight: { name: "Tokyo Night", colorScheme: "dark", bg: "#1a1b26", surface: "#24283b", elevated: "#292e42", code: "#16161e", text: "#c0caf5", muted: "#a9b1d6", border: "#414868", link: "#7aa2f7", }, catppuccinMocha: { name: "Catppuccin Mocha", colorScheme: "dark", bg: "#1e1e2e", surface: "#313244", elevated: "#45475a", code: "#181825", text: "#cdd6f4", muted: "#a6adc8", border: "#585b70", link: "#89b4fa", }, gruvbox: { name: "Gruvbox Dark", colorScheme: "dark", bg: "#282828", surface: "#3c3836", elevated: "#504945", code: "#1d2021", text: "#ebdbb2", muted: "#d5c4a1", border: "#665c54", link: "#83a598", }, monokai: { name: "Monokai", colorScheme: "dark", bg: "#272822", surface: "#34352f", elevated: "#3e3d32", code: "#1e1f1c", text: "#f8f8f2", muted: "#cfcfc2", border: "#75715e", link: "#66d9ef", }, solarizedDark: { name: "Solarized Dark", colorScheme: "dark", bg: "#002b36", surface: "#073642", elevated: "#0b3a46", code: "#00212b", text: "#eee8d5", muted: "#93a1a1", border: "#586e75", link: "#268bd2", }, }; const state = loadState(); try { exposeDebugApi(); debugLog("boot", { href: location.href, readyState: document.readyState, hasHead: Boolean(document.head), hasBody: Boolean(document.body), preset: state.preset, }); injectBootStyle(); applyTheme(); runWhenBodyReady(() => { debugLog("body-ready", { readyState: document.readyState, hasHead: Boolean(document.head), hasBody: Boolean(document.body), }); createPanel(); applyTheme(); keepThemeStyleLast(); }); } catch (error) { debugError("fatal", error); } function debugLog(message, detail = null) { if (!DEBUG) { return; } if (detail === null) { console.warn(LOG_PREFIX, message); return; } console.warn(LOG_PREFIX, message, detail); } function debugError(message, error) { console.error(LOG_PREFIX, message, error); } function exposeDebugApi() { window.__cleanThemeSwitcherDebug = { getState: () => JSON.parse(JSON.stringify(state)), getStatus: () => ({ active: document.documentElement.dataset.cleanThemeSwitcher, preset: document.documentElement.dataset.cleanThemePreset, hasBootStyle: Boolean(document.getElementById(BOOT_STYLE_ID)), hasThemeStyle: Boolean(document.getElementById(STYLE_ID)), hasPanel: Boolean(document.getElementById(PANEL_ID)), readyState: document.readyState, url: location.href, }), applyGray: () => { state.preset = "gray"; state.theme = { ...PRESETS.gray }; applyTheme(); }, disableTheme, showPanel: () => { runWhenBodyReady(() => { createPanel(); const host = document.getElementById(PANEL_ID); const panel = host?.shadowRoot?.getElementById("panel"); const toggle = host?.shadowRoot?.getElementById("showPanel"); panel?.classList.remove("hidden"); toggle?.classList.add("hidden"); debugLog("debug-api-show-panel"); }); }, }; debugLog("debug-api-exposed", "__cleanThemeSwitcherDebug"); } function loadState() { if (FORCE_GRAY_ON_PAGE_OPEN) { debugLog("load-state-force-default", DEFAULT_PRESET); return { preset: DEFAULT_PRESET, theme: { ...PRESETS[DEFAULT_PRESET] }, }; } try { const saved = JSON.parse(localStorage.getItem(STORAGE_KEY) || "null"); return { preset: saved?.preset || DEFAULT_PRESET, theme: { ...PRESETS[DEFAULT_PRESET], ...(saved?.theme || {}), }, }; } catch { return { preset: DEFAULT_PRESET, theme: { ...PRESETS[DEFAULT_PRESET] }, }; } } function saveState() { try { localStorage.setItem(STORAGE_KEY, JSON.stringify(state)); } catch { // 有些隐私窗口或特殊页面禁止 localStorage,主题本身仍然可以生效。 debugLog("save-state-skipped", "localStorage unavailable"); } } function runWhenBodyReady(callback) { if (document.body) { callback(); return; } const observer = new MutationObserver(() => { if (!document.body) { return; } observer.disconnect(); debugLog("body-detected-by-observer"); callback(); }); observer.observe(document.documentElement, { childList: true, subtree: true, }); } function appendStyleElement(style) { const target = document.head || document.documentElement; if (style.parentElement !== target || target.lastElementChild !== style) { target.appendChild(style); debugLog("style-appended", { id: style.id || "(no id)", target: target.tagName, }); } } function injectBootStyle() { if (document.getElementById(BOOT_STYLE_ID)) { return; } const theme = PRESETS.gray; const style = document.createElement("style"); style.id = BOOT_STYLE_ID; style.textContent = ` html, body { background: ${theme.bg} !important; color: ${theme.text} !important; color-scheme: ${theme.colorScheme} !important; } html { --cts-bg: ${theme.bg}; --cts-surface: ${theme.surface}; --cts-elevated: ${theme.elevated}; --cts-code: ${theme.code}; --cts-text: ${theme.text}; --cts-muted: ${theme.muted}; --cts-border: ${theme.border}; --cts-link: ${theme.link}; --cts-color-scheme: ${theme.colorScheme}; } `; appendStyleElement(style); debugLog("boot-style-installed"); } function normalizeColor(value, fallback) { if (!value) { return fallback; } const cleanValue = value.trim(); if (/^#[0-9a-f]{6}$/i.test(cleanValue)) { return cleanValue; } if (/^#[0-9a-f]{3}$/i.test(cleanValue)) { const [, r, g, b] = cleanValue; return `#${r}${r}${g}${g}${b}${b}`; } const rgbMatch = cleanValue.match(/rgba?\((\d+),\s*(\d+),\s*(\d+)/i); if (rgbMatch) { const [, r, g, b] = rgbMatch; return [r, g, b] .map((part) => Number(part).toString(16).padStart(2, "0")) .join("") .replace(/^/, "#"); } if (!document.body) { return fallback; } const probe = document.createElement("span"); probe.style.color = cleanValue; document.body.appendChild(probe); const computed = getComputedStyle(probe).color; probe.remove(); return normalizeColor(computed, fallback); } function isLightColor(hex) { const normalized = normalizeColor(hex, "#303134"); const cleanHex = normalized.replace("#", ""); const r = parseInt(cleanHex.slice(0, 2), 16); const g = parseInt(cleanHex.slice(2, 4), 16); const b = parseInt(cleanHex.slice(4, 6), 16); return (r * 299 + g * 587 + b * 114) / 1000 > 155; } function setCssVariables(theme) { themeEnabled = true; const root = document.documentElement; Object.entries(theme).forEach(([key, value]) => { root.style.setProperty(`--cts-${key}`, value); }); root.style.setProperty("--cts-color-scheme", theme.colorScheme || "dark"); root.style.colorScheme = theme.colorScheme || "dark"; root.dataset.cleanThemeSwitcher = "active"; root.dataset.cleanThemePreset = state.preset; debugLog("css-vars-set", { preset: state.preset, bg: theme.bg, text: theme.text, }); } function installPageStyle() { let style = document.getElementById(STYLE_ID); if (!style) { style = document.createElement("style"); style.id = STYLE_ID; } if (state.theme.mode === "native") { installNativeDarkStyle(style); return; } const cssText = ` html, body { background: var(--cts-bg) !important; color: var(--cts-text) !important; color-scheme: var(--cts-color-scheme, dark) !important; } body { min-height: 100vh !important; } :where( body, main, article, section, aside, header, footer, nav, p, span, li, ul, ol, dl, dt, dd, table, thead, tbody, tr, td, th, form, label, summary, figcaption, caption, legend, h1, h2, h3, h4, h5, h6, strong, b, em, i, small, time, mark ):not(#${PANEL_ID}):not(#${PANEL_ID} *) { color: var(--cts-text) !important; -webkit-text-fill-color: var(--cts-text) !important; } :where([style*="color"], [class*="title" i], [class*="heading" i], [class*="headline" i], [class*="text" i]):not(#${PANEL_ID}):not(#${PANEL_ID} *) { color: var(--cts-text) !important; -webkit-text-fill-color: var(--cts-text) !important; } :where([class*="muted" i], [class*="secondary" i], [class*="subtle" i], small, time):not(#${PANEL_ID}):not(#${PANEL_ID} *) { color: var(--cts-muted) !important; -webkit-text-fill-color: var(--cts-muted) !important; } :where(main, article, section, aside, header, footer, nav):not(#${PANEL_ID}):not(#${PANEL_ID} *) { background-color: var(--cts-bg) !important; } :where(blockquote):not(#${PANEL_ID}):not(#${PANEL_ID} *) { background-color: var(--cts-surface) !important; border-color: var(--cts-border) !important; } .${ADAPTED_SURFACE_CLASS}:not(#${PANEL_ID}):not(#${PANEL_ID} *) { background-color: var(--cts-surface) !important; border-color: var(--cts-border) !important; } .${ADAPTED_DARK_SURFACE_CLASS}:not(#${PANEL_ID}):not(#${PANEL_ID} *) { background-color: var(--cts-surface) !important; border-color: var(--cts-border) !important; } .${ADAPTED_SURFACE_CLASS}:where([class*="item" i], [class*="card" i], [class*="content" i]) { box-shadow: none !important; } .${ADAPTED_SURFACE_CLASS} :where(hr):not(#${PANEL_ID}):not(#${PANEL_ID} *) { border-color: var(--cts-border) !important; background-color: var(--cts-border) !important; } .${ADAPTED_CONTROL_CLASS}:not(#${PANEL_ID}):not(#${PANEL_ID} *) { background-color: var(--cts-elevated) !important; border-color: var(--cts-border) !important; color: var(--cts-text) !important; -webkit-text-fill-color: var(--cts-text) !important; } :where( input, textarea, select, [role="searchbox"], [class*="search" i], [class*="input" i], [class*="btn" i], [class*="button" i], [class*="tag" i], [class*="tab" i] ):not(#${PANEL_ID}):not(#${PANEL_ID} *) { border-color: var(--cts-border) !important; } :where( input, textarea, select, [role="searchbox"], [class*="search" i], [class*="input" i] ):not(#${PANEL_ID}):not(#${PANEL_ID} *) { background-color: var(--cts-elevated) !important; color: var(--cts-text) !important; -webkit-text-fill-color: var(--cts-text) !important; caret-color: var(--cts-text) !important; } :where(input::placeholder, textarea::placeholder) { color: var(--cts-muted) !important; -webkit-text-fill-color: var(--cts-muted) !important; opacity: 1 !important; } :where(a, a *, [href], [href] *):not(#${PANEL_ID}):not(#${PANEL_ID} *) { color: var(--cts-link) !important; -webkit-text-fill-color: var(--cts-link) !important; } :where(pre, code, kbd, samp):not(#${PANEL_ID}):not(#${PANEL_ID} *) { background: var(--cts-code) !important; color: var(--cts-text) !important; -webkit-text-fill-color: var(--cts-text) !important; border-color: var(--cts-border) !important; border-radius: 6px !important; } :where(pre):not(#${PANEL_ID}):not(#${PANEL_ID} *) { padding: 12px !important; overflow: auto !important; } :where(code, kbd, samp):not(#${PANEL_ID}):not(#${PANEL_ID} *) { padding: 2px 6px !important; } :where(img, video, canvas):not(#${PANEL_ID}):not(#${PANEL_ID} *) { max-width: 100% !important; } :where(hr):not(#${PANEL_ID}):not(#${PANEL_ID} *) { border-color: var(--cts-border) !important; background-color: var(--cts-border) !important; opacity: 0.72 !important; } ::selection { background: var(--cts-link) !important; color: var(--cts-bg) !important; -webkit-text-fill-color: var(--cts-bg) !important; } `; if (style.textContent !== cssText) { style.textContent = cssText; } appendStyleElement(style); debugLog("page-style-installed", { styleLength: style.textContent.length, }); } function installNativeDarkStyle(style) { const cssText = ` html { color-scheme: dark !important; background: var(--cts-bg) !important; } body { background: var(--cts-bg) !important; color: var(--cts-text) !important; color-scheme: dark !important; } :where(main, article, section):not(#${PANEL_ID}):not(#${PANEL_ID} *) { color-scheme: dark !important; } :where(pre, code, kbd, samp):not(#${PANEL_ID}):not(#${PANEL_ID} *) { color-scheme: dark !important; } :where(a):not(#${PANEL_ID}):not(#${PANEL_ID} *) { color: var(--cts-link) !important; } `; if (style.textContent !== cssText) { style.textContent = cssText; } appendStyleElement(style); debugLog("native-style-installed", { styleLength: style.textContent.length, }); } function applyTheme(theme = state.theme) { themeEnabled = true; applyThemeCore(theme); } function applyThemeCore(theme = state.theme) { debugLog("apply-theme-start", { preset: state.preset, readyState: document.readyState, }); setCssVariables(theme); installPageStyle(); if (theme.mode !== "native") { adaptLightSurfaces(); } else { clearAdaptedElements(); } syncPanelValues(theme); saveState(); debugLog("apply-theme-done"); } function disableTheme() { themeEnabled = false; document.getElementById(STYLE_ID)?.remove(); document.getElementById(BOOT_STYLE_ID)?.remove(); clearAdaptedElements(); Object.keys(PRESETS[DEFAULT_PRESET]).forEach((key) => { document.documentElement.style.removeProperty(`--cts-${key}`); }); document.documentElement.style.removeProperty("--cts-color-scheme"); document.documentElement.style.removeProperty("background"); document.documentElement.style.removeProperty("color"); document.documentElement.style.removeProperty("color-scheme"); delete document.documentElement.dataset.cleanThemeSwitcher; delete document.documentElement.dataset.cleanThemePreset; debugLog("theme-disabled"); } function adaptLightSurfaces() { clearAdaptedElements(); const candidates = document.querySelectorAll( [ "body > div", "main", "main div", "main section", "main article", "article", "article div", "section", "section div", "aside", "aside div", "header", "header div", "footer", "footer div", "nav", "nav div", "[role='main']", "[role='main'] div", ].join(","), ); candidates.forEach((element) => { if (!(element instanceof HTMLElement)) { return; } if (element.id === PANEL_ID || element.closest(`#${PANEL_ID}`)) { return; } if (shouldAdaptSurface(element)) { element.classList.add(ADAPTED_SURFACE_CLASS); } if (shouldAdaptDarkSurface(element)) { element.classList.add(ADAPTED_DARK_SURFACE_CLASS); } }); adaptLightControls(); } function clearAdaptedElements() { document .querySelectorAll( `.${ADAPTED_SURFACE_CLASS}, .${ADAPTED_DARK_SURFACE_CLASS}, .${ADAPTED_CONTROL_CLASS}`, ) .forEach((element) => { element.classList.remove( ADAPTED_SURFACE_CLASS, ADAPTED_DARK_SURFACE_CLASS, ADAPTED_CONTROL_CLASS, ); }); } function adaptLightControls() { const controls = document.querySelectorAll( [ "input", "textarea", "select", "button", "[role='button']", "[role='searchbox']", "[class*='search' i]", "[class*='input' i]", "[class*='btn' i]", "[class*='button' i]", "[class*='tag' i]", "[class*='tab' i]", ].join(","), ); controls.forEach((element) => { if (!(element instanceof HTMLElement)) { return; } if (element.id === PANEL_ID || element.closest(`#${PANEL_ID}`)) { return; } const rect = element.getBoundingClientRect(); if (rect.width < 36 || rect.height < 24 || rect.width > 760 || rect.height > 96) { return; } const style = getComputedStyle(element); if (isLightCssColor(style.backgroundColor)) { element.classList.add(ADAPTED_CONTROL_CLASS); } }); } function shouldAdaptSurface(element) { if ((state.theme.colorScheme || "dark") !== "dark") { return false; } const rect = element.getBoundingClientRect(); if (rect.width < 180 || rect.height < 48) { return false; } if (isInteractiveSurface(element)) { return false; } const viewportHeight = window.innerHeight || document.documentElement.clientHeight; if (rect.bottom < 0 || rect.top > viewportHeight) { return false; } const style = getComputedStyle(element); if (style.display === "none" || style.visibility === "hidden") { return false; } if (style.backgroundImage && style.backgroundImage !== "none") { return false; } return isLightCssColor(style.backgroundColor); } function shouldAdaptDarkSurface(element) { if ((state.theme.colorScheme || "dark") !== "light") { return false; } const rect = element.getBoundingClientRect(); if (rect.width < 180 || rect.height < 48) { return false; } if (isInteractiveSurface(element)) { return false; } const viewportHeight = window.innerHeight || document.documentElement.clientHeight; if (rect.bottom < 0 || rect.top > viewportHeight) { return false; } const style = getComputedStyle(element); if (style.display === "none" || style.visibility === "hidden") { return false; } if (style.backgroundImage && style.backgroundImage !== "none") { return false; } return isDarkCssColor(style.backgroundColor); } function isInteractiveSurface(element) { const tagName = element.tagName.toLowerCase(); if ( [ "a", "button", "input", "select", "textarea", "label", "svg", "img", "video", "canvas", ].includes(tagName) ) { return true; } if (element.matches("[role='button'], [role='search'], [contenteditable='true']")) { return true; } const rect = element.getBoundingClientRect(); const looksLikeSearchBox = rect.height < 64 && Boolean(element.querySelector("input, textarea, [role='searchbox']")); return looksLikeSearchBox; } function isLightCssColor(color) { const rgba = color.match(/rgba?\((\d+),\s*(\d+),\s*(\d+)(?:,\s*([.\d]+))?\)/i); if (!rgba) { return false; } const alpha = rgba[4] === undefined ? 1 : Number(rgba[4]); if (alpha < 0.65) { return false; } const red = Number(rgba[1]); const green = Number(rgba[2]); const blue = Number(rgba[3]); return (red * 299 + green * 587 + blue * 114) / 1000 > 210; } function isDarkCssColor(color) { const rgba = color.match(/rgba?\((\d+),\s*(\d+),\s*(\d+)(?:,\s*([.\d]+))?\)/i); if (!rgba) { return false; } const alpha = rgba[4] === undefined ? 1 : Number(rgba[4]); if (alpha < 0.65) { return false; } const red = Number(rgba[1]); const green = Number(rgba[2]); const blue = Number(rgba[3]); return (red * 299 + green * 587 + blue * 114) / 1000 < 72; } function createPanel() { if (document.getElementById(PANEL_ID)) { debugLog("create-panel-skipped", "already exists"); return; } debugLog("create-panel-start"); const host = document.createElement("div"); host.id = PANEL_ID; const shadow = host.attachShadow({ mode: "open" }); const presetOptions = Object.entries(PRESETS) .map(([value, theme]) => ``) .join(""); shadow.innerHTML = `
Theme Switcher
如果本地 HTML 无效,请在浏览器扩展设置中允许 Tampermonkey 访问 file:// URL。
`; document.body.appendChild(host); debugLog("panel-appended"); bindPanelEvents(shadow); syncPanelValues(state.theme); debugLog("create-panel-done"); } function bindPanelEvents(shadow) { const $ = (id) => shadow.getElementById(id); $("preset").addEventListener("change", () => { const preset = $("preset").value; debugLog("preset-change", preset); if (PRESETS[preset]) { state.preset = preset; state.theme = { ...PRESETS[preset] }; $("colors").classList.add("hidden"); applyTheme(); return; } state.preset = "custom"; $("colors").classList.remove("hidden"); saveState(); }); $("toggleColors").addEventListener("click", () => { $("colors").classList.toggle("hidden"); debugLog("toggle-colors", { hidden: $("colors").classList.contains("hidden"), }); }); $("apply").addEventListener("click", () => { debugLog("apply-button-click"); state.preset = "custom"; state.theme = readPanelTheme(shadow); applyTheme(); }); $("reset").addEventListener("click", () => { debugLog("reset-button-click"); $("colors").classList.add("hidden"); if (state.preset === "custom") { state.preset = DEFAULT_PRESET; state.theme = { ...PRESETS[DEFAULT_PRESET] }; applyTheme(); return; } disableTheme(); }); $("hidePanel").addEventListener("click", () => { debugLog("hide-panel-click"); $("panel").classList.add("hidden"); $("showPanel").classList.remove("hidden"); }); $("showPanel").addEventListener("click", () => { debugLog("show-panel-click"); $("panel").classList.remove("hidden"); $("showPanel").classList.add("hidden"); }); } function readPanelTheme(shadow) { const read = (id) => shadow.getElementById(id).value; return { name: "Custom", colorScheme: isLightColor(read("bg")) ? "light" : "dark", bg: read("bg"), surface: read("surface"), elevated: read("elevated"), code: read("code"), text: read("text"), muted: read("muted"), border: read("border"), link: read("link"), }; } function syncPanelValues(theme) { const host = document.getElementById(PANEL_ID); if (!host?.shadowRoot) { return; } const shadow = host.shadowRoot; const set = (id, value) => { const element = shadow.getElementById(id); if (element && value) { element.value = value; } }; set("preset", state.preset); set("bg", theme.bg); set("surface", theme.surface); set("elevated", theme.elevated); set("code", theme.code); set("text", theme.text); set("muted", theme.muted); set("border", theme.border); set("link", theme.link); } function keepThemeStyleLast() { let pending = false; const observer = new MutationObserver(() => { if (pending) { return; } pending = true; window.setTimeout(() => { pending = false; if (!themeEnabled) { return; } applyThemeCore(); }, 120); }); observer.observe(document.body || document.documentElement, { childList: true, subtree: true, }); } })();