// ==UserScript== // @name Clean Theme Switcher // @namespace local.clean-theme-switcher // @version 0.2.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 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]"; const PRESETS = { gray: { name: "Gray", colorScheme: "dark", bg: "#303134", surface: "#202124", elevated: "#1f2023", code: "#202124", text: "#f1f3f4", muted: "#c6c9ce", border: "#72777f", link: "#8ab4f8", }, 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", }, }; 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(); }, 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) { 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; } 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; } :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; } ::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 applyTheme(theme = state.theme) { debugLog("apply-theme-start", { preset: state.preset, readyState: document.readyState, }); setCssVariables(theme); installPageStyle(); syncPanelValues(theme); saveState(); debugLog("apply-theme-done"); } 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" }); shadow.innerHTML = `