Light Force

May the Light Force be with you! Forces dark-themed websites into light mode, leaving originally light websites unaffected.

You will need to install an extension such as Tampermonkey, Greasemonkey or Violentmonkey to install this script.

You will need to install an extension such as Tampermonkey to install this script.

You will need to install an extension such as Tampermonkey or Violentmonkey to install this script.

You will need to install an extension such as Tampermonkey or Userscripts to install this script.

You will need to install an extension such as Tampermonkey to install this script.

You will need to install a user script manager extension to install this script.

(I already have a user script manager, let me install it!)

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

ستحتاج إلى تثبيت إضافة مثل Stylus لتثبيت هذا النمط.

ستحتاج إلى تثبيت إضافة لإدارة أنماط المستخدم لتتمكن من تثبيت هذا النمط.

ستحتاج إلى تثبيت إضافة لإدارة أنماط المستخدم لتثبيت هذا النمط.

ستحتاج إلى تثبيت إضافة لإدارة أنماط المستخدم لتثبيت هذا النمط.

(لدي بالفعل مثبت أنماط للمستخدم، دعني أقم بتثبيته!)

// ==UserScript==
// @name         Light Force
// @name:zh      Light Force (光明原力)
// @namespace    https://ct106.com
// @version      1.3.7
// @description  May the Light Force be with you! Forces dark-themed websites into light mode, leaving originally light websites unaffected.
// @description:zh 愿光明原力与你同在!将深色模式网站强制转为浅色模式,不影响原生浅色网站。
// @author       chentao1006
// @match        *://*/*
// @grant        GM_getValue
// @grant        GM_setValue
// @grant        GM_registerMenuCommand
// @run-at       document-start
// @allFrames     true
// ==/UserScript==

(function () {
  'use strict';

  const lang = navigator.language.startsWith('zh') ? 'zh' : 'en';
  const i18n = {
    zh: {
      enabled: '已启用',
      disabled: '已禁用',
      yes: '是',
      no: '否',
      enableForce: '启用 强制网页浅色',
      disableForce: '禁用 强制网页浅色',
      enableDaylight: '启用 仅当系统浅色',
      disableDaylight: '禁用 仅当系统浅色',
      status: '状态',
      onlyDaylight: '仅当系统浅色',
      darkDetected: '探测到深色页面 — 应用滤镜反转',
      lightDetected: '页面为浅色 — 检查深色容器',
      dynamicDark: '页面动态变为深色 — 应用滤镜反转',
      daylightActive: '已开启“仅当系统浅色”,且系统当前处于深色模式。跳过。',
      runMode: '生效范围',
      exclusionMode: '除名单外所有网站',
      inclusionMode: '仅名单内网站',
      addToList: '将当前网站加入名单',
      removeFromList: '从名单中移除当前网站',
      siteInList: '当前网站已在名单中',
      siteNotInList: '当前网站不在名单中'
    },
    en: {
      enabled: 'Enabled',
      disabled: 'Disabled',
      yes: 'Yes',
      no: 'No',
      enableForce: 'Enable Light Force',
      disableForce: 'Disable Light Force',
      enableDaylight: 'Enable Only in System Light Mode',
      disableDaylight: 'Disable Only in System Light Mode',
      status: 'Status',
      onlyDaylight: 'Only in System Light Mode',
      darkDetected: 'Page detected as dark — applying filter inversion',
      lightDetected: 'Page is light — checking for dark containers',
      dynamicDark: 'Page turned dark dynamically — applying filter inversion',
      daylightActive: '"Only in System Light Mode" is active and system is currently in Dark Mode. Skipping.',
      runMode: 'Run Mode',
      exclusionMode: 'All sites except list',
      inclusionMode: 'Specific sites only',
      addToList: 'Add site to list',
      removeFromList: 'Remove site from list',
      siteInList: 'Current site is in list',
      siteNotInList: 'Current site is not in list'
    }
  }[lang];

  let isEnabled = GM_getValue('lightForceEnabled', true);
  let isOnlyDaylight = GM_getValue('onlyDaylightEnabled', false);
  let runMode = GM_getValue('runMode', 'exclusion');
  let siteList = GM_getValue('siteList', []);
  const hostname = window.location.hostname;

  console.log(`[Light Force] ${i18n.status}:`, isEnabled ? i18n.enabled : i18n.disabled, `| ${i18n.onlyDaylight}:`, isOnlyDaylight ? i18n.yes : i18n.no, `| ${i18n.runMode}:`, runMode === 'exclusion' ? i18n.exclusionMode : i18n.inclusionMode);

  GM_registerMenuCommand(isEnabled ? i18n.disableForce : i18n.enableForce, () => {
    GM_setValue('lightForceEnabled', !isEnabled);
    location.reload();
  });

  GM_registerMenuCommand(isOnlyDaylight ? i18n.disableDaylight : i18n.enableDaylight, () => {
    GM_setValue('onlyDaylightEnabled', !isOnlyDaylight);
    location.reload();
  });

  GM_registerMenuCommand(`${i18n.runMode}: ${runMode === 'exclusion' ? i18n.exclusionMode : i18n.inclusionMode}`, () => {
    GM_setValue('runMode', runMode === 'exclusion' ? 'inclusion' : 'exclusion');
    location.reload();
  });

  const isInList = siteList.includes(hostname);
  GM_registerMenuCommand(isInList ? i18n.removeFromList : i18n.addToList, () => {
    let newList = [...siteList];
    if (isInList) {
      newList = newList.filter(s => s !== hostname);
    } else {
      newList.push(hostname);
    }
    GM_setValue('siteList', newList);
    location.reload();
  });

  if (isEnabled) {
    if (runMode === 'exclusion' && isInList) {
      console.log(`[Light Force] ${i18n.siteInList}. Skipping.`);
      return;
    }
    if (runMode === 'inclusion' && !isInList) {
      console.log(`[Light Force] ${i18n.siteNotInList}. Skipping.`);
      return;
    }

    if (isOnlyDaylight && window.matchMedia('(prefers-color-scheme: dark)').matches) {
      console.log(`[Light Force] ${i18n.daylightActive}`);
      return;
    }
    applyLightForce();
  }

  // ─── Utility: WCAG relative luminance ─────────────────────────────────────
  function getLuminance(r, g, b) {
    const [rs, gs, bs] = [r, g, b].map(c => {
      c = c / 255;
      return c <= 0.03928 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4);
    });
    return 0.2126 * rs + 0.7152 * gs + 0.0722 * bs;
  }

  function parseColor(colorStr) {
    if (!colorStr || colorStr === 'transparent' || colorStr === 'rgba(0, 0, 0, 0)') return null;
    const match = colorStr.match(/rgba?\((\d+),\s*(\d+),\s*(\d+)(?:,\s*([\d.]+))?\)/);
    if (match) {
      const a = match[4] !== undefined ? parseFloat(match[4]) : 1;
      if (a < 0.1) return null;
      return { r: parseInt(match[1]), g: parseInt(match[2]), b: parseInt(match[3]), a };
    }
    return null;
  }

  function isDarkColor(r, g, b) { return getLuminance(r, g, b) < 0.12; }
  function isLightColor(r, g, b) { return getLuminance(r, g, b) > 0.4; }

  function isTextDark(el) {
    if (!el) return false;
    const style = window.getComputedStyle(el);
    const color = parseColor(style.color);
    return color && getLuminance(color.r, color.g, color.b) < 0.25;
  }

  // ─── Extract effective background from an element ─────────────────────────
  function getEffectiveBackground(el) {
    if (!el) return null;
    const style = window.getComputedStyle(el);
    const bg = parseColor(style.backgroundColor);
    if (bg) return bg;
    const bgImage = style.backgroundImage;
    if (bgImage && bgImage !== 'none') {
      const gradientColors = bgImage.match(/rgba?\(\d+,\s*\d+,\s*\d+(?:,\s*[\d.]+)?\)/g);
      if (gradientColors) {
        let dc = 0;
        for (const cs of gradientColors) { const c = parseColor(cs); if (c && isDarkColor(c.r, c.g, c.b)) dc++; }
        if (dc > gradientColors.length / 2) return parseColor(gradientColors[0]);
      }
    }
    return null;
  }

  // ─── Detect if the page is dark (multi-strategy) ──────────────────────────
  function isPageDark() {
    // Strategy 0: Master Light Override
    const mt = document.querySelectorAll('div, section, main, article');
    for (const el of mt) {
      const r = el.getBoundingClientRect();
      if (r.width > window.innerWidth * 0.6 && r.height > window.innerHeight * 0.4) {
        const bg = getEffectiveBackground(el);
        if (bg && isLightColor(bg.r, bg.g, bg.b)) return false;
      }
    }

    // Strategy 1: html and body
    for (const el of [document.documentElement, document.body]) {
      if (!el) continue;

      // --- NEW: Check for explicit color-scheme property ---
      const style = window.getComputedStyle(el);
      if (style.colorScheme === 'dark') return true;

      const bg = getEffectiveBackground(el);
      if (!bg) continue;

      // --- NEW: Definitive Light Signal ---
      if (isLightColor(bg.r, bg.g, bg.b)) return false;

      // --- NEW: Relaxed explicit check for body/html ---
      if (isDarkColor(bg.r, bg.g, bg.b)) {
        const luminance = getLuminance(bg.r, bg.g, bg.b);
        if (luminance < 0.05 && !isTextDark(el)) return true;
        if (!isTextDark(el)) return true;
      }
    }

    // Strategy 1.5: Check meta tags
    const themeColorMeta = document.querySelector('meta[name="theme-color"]');
    if (themeColorMeta) {
      const content = themeColorMeta.getAttribute('content');
      if (content && content.toLowerCase() === '#000000') return true;
    }
    const statusBarStyle = document.querySelector('meta[name="apple-mobile-web-app-status-bar-style"]');
    if (statusBarStyle && statusBarStyle.getAttribute('content') === 'black') return true;

    // Strategy 2: Sample first-level children
    if (document.body) {
      const children = document.body.children;
      let db = 0, dt = 0, s = 0;
      for (let i = 0; i < Math.min(children.length, 12); i++) {
        const child = children[i];
        if (!child || ['SCRIPT', 'STYLE', 'LINK', 'NOSCRIPT'].includes(child.tagName)) continue;
        const rect = child.getBoundingClientRect();
        if (rect.width < 50 || rect.height < 20) continue;
        const bg = getEffectiveBackground(child);
        if (bg && isDarkColor(bg.r, bg.g, bg.b)) db++;
        if (isTextDark(child)) dt++;
        s++;
      }
      if (s > 0 && db / s >= 0.4 && dt / s < 0.3) return true;
    }

    // Strategy 3: elementFromPoint sampling
    try {
      const vw = window.innerWidth, vh = window.innerHeight;
      const points = [[vw * 0.5, vh * 0.1], [vw * 0.5, vh * 0.5], [vw * 0.1, vh * 0.5], [vw * 0.9, vh * 0.5], [vw * 0.5, vh * 0.9]];
      let db = 0, dt = 0, ts = 0;
      for (const [x, y] of points) {
        const el = document.elementFromPoint(x, y);
        if (!el) continue;
        let curr = el, foundBg = false;
        while (curr && curr !== document.documentElement) {
          const bg = getEffectiveBackground(curr);
          if (bg) { if (isDarkColor(bg.r, bg.g, bg.b)) db++; foundBg = true; break; }
          curr = curr.parentElement;
        }
        if (foundBg) { if (isTextDark(el)) dt++; ts++; }
      }
      if (ts >= 3 && db / ts >= 0.5 && dt / ts < 0.3) return true;
    } catch (e) { }

    // Strategy 4: Recursive descent into large containers
    if (document.body) {
      const queue = [...document.body.children];
      let depth = 0;
      while (queue.length > 0 && depth < 3) {
        depth++;
        const nextQueue = [];
        for (const child of queue) {
          if (!child || !child.getBoundingClientRect) continue;
          if (['SCRIPT', 'STYLE', 'LINK', 'NOSCRIPT'].includes(child.tagName)) continue;
          const rect = child.getBoundingClientRect();
          if (rect.width < window.innerWidth * 0.5 || rect.height < window.innerHeight * 0.3) continue;
          const bg = getEffectiveBackground(child);
          if (bg && isDarkColor(bg.r, bg.g, bg.b)) return true;
          if (child.children) nextQueue.push(...child.children);
        }
        queue.length = 0;
        queue.push(...nextQueue);
      }
    }
    return false;
  }

  // ─── Phase 1: Flip known theme signals ────────────────────────────────────
  function flipThemeSignals() {
    if (!document.getElementById('light-force-color-scheme')) {
      const style = document.createElement('style');
      style.id = 'light-force-color-scheme';
      style.textContent = ':root, html, body { color-scheme: light !important; }';
      (document.head || document.documentElement).appendChild(style);
    }

    const root = document.documentElement;
    if (root) {
      if (root.classList.contains('dark')) { root.classList.remove('dark'); root.classList.add('light'); }
      ['dark-mode', 'dark-theme', 'theme-dark', 'night', 'night-mode', 'theme-system'].forEach(cls => {
        if (root.classList.contains(cls)) {
          root.classList.remove(cls);
          if (cls === 'theme-system') root.classList.add('theme-light');
        }
      });
      ['theme', 'data-theme', 'data-color-mode', 'data-color-scheme', 'data-mode', 'data-appearance', 'data-bs-theme'].forEach(attr => {
        const val = root.getAttribute(attr);
        if (val && /dark|night/i.test(val)) root.setAttribute(attr, val.replace(/dark|night/gi, 'light'));
      });
      const inlineStyle = root.getAttribute('style') || '';
      if (/color-scheme:\s*dark/i.test(inlineStyle)) {
        root.setAttribute('style', inlineStyle.replace(/color-scheme:\s*dark/gi, 'color-scheme: light'));
      }
    }

    const body = document.body;
    if (body) {
      if (body.classList.contains('dark')) { body.classList.remove('dark'); body.classList.add('light'); }
      ['dark-mode', 'dark-theme', 'theme-dark', 'night', 'night-mode', 'theme-system'].forEach(cls => {
        if (body.classList.contains(cls)) {
          body.classList.remove(cls);
          if (cls === 'theme-system') body.classList.add('theme-light');
        }
      });
      ['data-theme', 'data-color-mode', 'data-bs-theme'].forEach(attr => {
        const val = body.getAttribute(attr);
        if (val && /dark|night/i.test(val)) body.setAttribute(attr, val.replace(/dark|night/gi, 'light'));
      });
    }

    if (!document.getElementById('light-force-theme-overrides')) {
      const overrideStyle = document.createElement('style');
      overrideStyle.id = 'light-force-theme-overrides';
      overrideStyle.textContent = `
        :root[data-theme="dark"], :root.dark,
        [data-theme="dark"] body, .dark body {
          --background: 0 0% 100% !important;
          --foreground: 222.2 84% 4.9% !important;
          background-color: white !important;
          color: #1a1a1a !important;
        }
      `;
      (document.head || document.documentElement).appendChild(overrideStyle);
    }
  }

  // ─── Phase 3: Universal CSS filter inversion ──────────────────────────────
  function applyFilterInversion() {
    if (document.getElementById('light-force-invert')) return;
    const invertStyle = document.createElement('style');
    invertStyle.id = 'light-force-invert';
    invertStyle.textContent = `
      html { filter: invert(1) hue-rotate(180deg) !important; }
      img, video, canvas, .emoji, iframe { filter: invert(1) hue-rotate(180deg) !important; }
      svg image { filter: invert(1) hue-rotate(180deg) !important; }
    `;
    (document.head || document.documentElement).appendChild(invertStyle);
    requestAnimationFrame(() => { reInvertBackgroundImages(); });
  }

  function reInvertBackgroundImages() {
    if (document.getElementById('light-force-bg-reinvert')) return;
    const walker = document.createTreeWalker(document.body || document.documentElement, NodeFilter.SHOW_ELEMENT, null);
    const leafRules = [], containerRules = [];
    let count = 0, node;
    while ((node = walker.nextNode()) && count < 5000) {
      count++;
      const style = window.getComputedStyle(node);
      const bg = style.backgroundImage;
      if (!bg || bg === 'none' || !bg.includes('url(')) continue;
      const uid = 'lf-' + Math.random().toString(36).substr(2, 6);
      node.setAttribute('data-lf-bg', uid);

      const hasMedia = node.querySelector('img, video, canvas, iframe');
      const isLarge = node.offsetWidth > window.innerWidth * 0.4 && node.offsetHeight > window.innerHeight * 0.4;
      if (hasMedia || isLarge || node === document.body || node === document.documentElement) {
        containerRules.push(`
          [data-lf-bg="${uid}"] { position: relative !important; background-image: none !important; z-index: 0 !important; }
          [data-lf-bg="${uid}"]::before {
            content: "" !important; position: absolute !important; top: 0 !important; left: 0 !important; width: 100% !important; height: 100% !important;
            background-image: ${bg} !important; background-size: ${style.backgroundSize} !important; background-position: ${style.backgroundPosition} !important;
            background-repeat: ${style.backgroundRepeat} !important; background-attachment: ${style.backgroundAttachment} !important;
            filter: invert(1) hue-rotate(180deg) !important; z-index: -1 !important; pointer-events: none !important; opacity: ${style.opacity} !important;
          }
        `);
      } else {
        leafRules.push(`[data-lf-bg="${uid}"] { filter: invert(1) hue-rotate(180deg) !important; }`);
      }
    }
    if (leafRules.length > 0 || containerRules.length > 0) {
      const bgStyle = document.createElement('style');
      bgStyle.id = 'light-force-bg-reinvert';
      bgStyle.textContent = leafRules.join('\n') + '\n' + containerRules.join('\n');
      document.head.appendChild(bgStyle);
    }
  }

  // ─── Phase 4: Illuminate specific dark containers (for mixed-theme sites) ────
  function illuminateSpecificDarkAreas() {
    if (document.getElementById('light-force-invert')) return;
    const selector = 'header, footer, nav, aside, [class*="header"], [class*="nav"], [class*="footer"], [class*="banner"], [class*="topbar"]';
    const targets = document.querySelectorAll(selector);
    targets.forEach(el => {
      if (el.hasAttribute('data-lf-illuminated')) return;
      const rect = el.getBoundingClientRect();
      if (rect.width < 100 || rect.height < 20) return;
      const bg = getEffectiveBackground(el);
      if (bg && isDarkColor(bg.r, bg.g, bg.b)) {
        el.setAttribute('data-lf-illuminated', 'true');
        el.style.filter = 'invert(1) hue-rotate(180deg)';
        el.querySelectorAll('img, video, canvas, svg, [style*="background-image"]').forEach(media => {
          media.style.filter = 'invert(1) hue-rotate(180deg)';
        });
      }
    });
  }

  // ─── Main ──────────────────────────────────────────────────────────────────
  function applyLightForce() {
    flipThemeSignals();

    const detectAndFix = () => {
      flipThemeSignals();
      requestAnimationFrame(() => {
        if (isPageDark()) {
          console.log(`[Light Force] ${i18n.darkDetected}`);
          applyFilterInversion();
        } else {
          console.log(`[Light Force] ${i18n.lightDetected}`);
          illuminateSpecificDarkAreas();
        }
      });
    };

    if (document.readyState === 'loading') {
      document.addEventListener('DOMContentLoaded', () => { setTimeout(detectAndFix, 50); });
    } else {
      setTimeout(detectAndFix, 50);
    }

    window.addEventListener('load', () => { setTimeout(detectAndFix, 200); });

    const observer = new MutationObserver(() => {
      clearTimeout(observer._timer);
      observer._timer = setTimeout(() => {
        flipThemeSignals();
        if (!document.getElementById('light-force-invert')) {
          requestAnimationFrame(() => {
            if (isPageDark()) {
              console.log(`[Light Force] ${i18n.dynamicDark}`);
              applyFilterInversion();
            } else {
              illuminateSpecificDarkAreas();
            }
          });
        }
      }, 200);
    });

    if (document.documentElement) {
      observer.observe(document.documentElement, {
        attributes: true, childList: true, subtree: true, // Improved SPA support
        attributeFilter: ['class', 'theme', 'data-theme', 'data-color-mode', 'style', 'data-bs-theme']
      });
    }
  }
})();