Global Black

A global black dark mode

// ==UserScript==
// @name         Global Black
// @namespace    github.com/annaroblox
// @version      2.1
// @description  A global black dark mode
// @author       annaroblox
// @match        */*
// @grant        none
// @run-at       document-start
// @license      MIT
// ==/UserScript==

(function () {
  "use strict";

  // --- CONFIGURATION ---
  const LIGHT_BACKGROUND_THRESHOLD = 400;
  const DARK_GREY_BG_THRESHOLD = 500;
  const DARK_TEXT_THRESHOLD = 128;

  const TARGET_BACKGROUND_COLOR = "#000000";
  const TARGET_TEXT_COLOR = "#FFFFFF";
  const TARGET_BORDER_COLOR = "#000000"; // change this if you want borders to be distinct

  const IGNORED_TAGS = ["IMG", "PICTURE", "VIDEO", "CANVAS", "SVG"];

  // --- IMMEDIATE STYLE INJECTION (RUNS BEFORE DOM IS READY) ---
  // This is the most important part for an instant effect and preventing a "flash of white".
  const style = document.createElement("style");
  style.id = "pure-black-mode-global-style";
  style.textContent = `
        /* Force dark scrollbars and form controls for a consistent experience */
        :root {
            color-scheme: dark !important;
        }
        /* Instantly apply to the base elements to prevent flash of white */
        html, #text, body, mt-sm, recent-posts,  article, header, footer, nav, main, aside,
        ul, ol, li, dl, table,  tr, td, overlay, label, #content, theme-auto,  th, thead, tbody, tfoot, style-scope,
        form, fieldset, button, section {
            background-color: ${TARGET_BACKGROUND_COLOR} !important;
            background: ${TARGET_BACKGROUND_COLOR} !important;
            color: ${TARGET_TEXT_COLOR} !important;
        }
        /* Handle syntax highlighting blocks gracefully */
        pre, code {
           background-color: #000000 !important;
           color: #D4D4D4 !important;
        }
    `;
  // Using document.documentElement ensures this runs as early as possible.
  document.documentElement.appendChild(style);

  // --- SCRIPT LOGIC (RUNS ONCE DOM IS INTERACTIVE) ---

  // A single, temporary div is used for all color computations to avoid DOM thrashing.
  const tempDiv = document.createElement("div");
  tempDiv.style.display = "none";
  document.documentElement.appendChild(tempDiv);

  // Cache for memoizing color lightness calculations to boost performance.
  const colorLightnessCache = new Map();

  /**
   * Calculates the "lightness" of a CSS color string, with caching.
   * @param {string} colorString - The CSS color (e.g., "rgb(255, 255, 255)", "#FFF", "white").
   * @returns {number} A lightness value from 0 (black) to 255 (white), or -1 if invalid/transparent.
   */
  function getColorLightness(colorString) {
    if (
      !colorString ||
      colorString === "none" ||
      colorString.includes("inherit") ||
      colorString.includes("initial") ||
      colorString.includes("unset")
    ) {
      return -1;
    }

    // Return from cache if value already computed.
    if (colorLightnessCache.has(colorString)) {
      return colorLightnessCache.get(colorString);
    }

    // Use the temporary div to resolve the color to a consistent rgb() format.
    tempDiv.style.color = colorString;
    const computedColor = window.getComputedStyle(tempDiv).color;

    if (computedColor === "rgba(0, 0, 0, 0)" || !computedColor) {
      colorLightnessCache.set(colorString, -1); // Cache transparent/invalid colors.
      return -1;
    }

    const match = computedColor.match(/rgba?\((\d+),\s*(\d+),\s*(\d+)/);
    let result = -1;
    if (match) {
      const [r, g, b] = [
        parseInt(match[1]),
        parseInt(match[2]),
        parseInt(match[3]),
      ];
      // Using a simple average is fast and sufficient for this script's purpose.
      result = (r + g + b) / 3;
    }

    colorLightnessCache.set(colorString, result);
    return result;
  }

  /**
   * The core function that processes a single element.
   * @param {HTMLElement} element - The DOM element to process.
   */
  function processElement(element) {
    // Basic checks to quickly exit for invalid or already-processed elements.
    if (
      !element ||
      element.nodeType !== 1 ||
      IGNORED_TAGS.includes(element.tagName)
    ) {
      return;
    }

    const style = window.getComputedStyle(element);

    // Ignore elements that are not visible.
    if (style.display === "none" || style.visibility === "hidden") {
      return;
    }

    const bgLightness = getColorLightness(style.backgroundColor);

    if (bgLightness === -1) return; // Skip transparent backgrounds, they'll inherit the parent's black.

    const isLight = bgLightness > LIGHT_BACKGROUND_THRESHOLD;
    const isDarkGrey = bgLightness > 0 && bgLightness < DARK_GREY_BG_THRESHOLD;

    if (isLight || isDarkGrey) {
      // If the element has no background image, we can safely use the 'background' shorthand property.
      // This is more powerful and overrides combined properties like `background: linear-gradient(...) #FFF;`.
      if (style.backgroundImage === "none") {
        element.style.setProperty(
          "background",
          TARGET_BACKGROUND_COLOR,
          "important",
        );
      } else {
        // If it has a background image, only change the color to avoid removing the image.
        element.style.setProperty(
          "background-color",
          TARGET_BACKGROUND_COLOR,
          "important",
        );
      }

      // Adjust text color for readability if it's dark.
      const textLightness = getColorLightness(style.color);
      if (textLightness !== -1 && textLightness < DARK_TEXT_THRESHOLD) {
        element.style.setProperty("color", TARGET_TEXT_COLOR, "important");
      }

      // Adjust border colors.
      const borderTargets = [
        "border-color",
        "border-top-color",
        "border-right-color",
        "border-bottom-color",
        "border-left-color",
      ];
      for (const prop of borderTargets) {
        const borderLightness = getColorLightness(style[prop]);
        // Check if the border is not already dark. Removed redundant '> 0' check.
        if (borderLightness > DARK_GREY_BG_THRESHOLD) {
          element.style.setProperty(prop, TARGET_BORDER_COLOR, "important");
        }
      }
    }
  }

  /**
   * Traverses a node and its children (including inside Shadow DOMs) to apply the black mode.
   * @param {Node} rootNode - The starting node (usually document.body or a new element).
   */
  function applyBlackModeToTree(rootNode) {
    if (!rootNode || typeof rootNode.querySelectorAll !== "function") {
      return;
    }

    // Process the root node itself first (important for single added nodes and shadow roots).
    if (rootNode.nodeType === 1) {
      processElement(rootNode);
    }

    const elements = rootNode.querySelectorAll("*");
    elements.forEach((el) => {
      processElement(el);
      // If an element has a shadow root, we need to recursively process its contents too.
      if (el.shadowRoot) {
        applyBlackModeToTree(el.shadowRoot);
      }
    });
  }

  /**
   * Applies the dark theme logic to a given document (e.g., the main document or an iframe's document).
   * @param {Document} doc - The document to process.
   */
  function applyThemeToDocument(doc) {
    if (
      !doc ||
      !doc.documentElement ||
      doc.documentElement.dataset.globalBlackApplied
    ) {
      return;
    }
    console.log(
      "Global Black: Applying theme to new document...",
      doc.location?.href,
    );
    doc.documentElement.dataset.globalBlackApplied = "true";

    // 1. Inject the main style sheet into the new document.
    const newStyle = doc.createElement("style");
    newStyle.id = "pure-black-mode-global-style-injected";
    newStyle.textContent = style.textContent; // `style` is the global style from the parent script.
    doc.documentElement.appendChild(newStyle);

    // 2. Run a full conversion on the new document's body.
    applyBlackModeToTree(doc.documentElement);

    // 3. Set up a new observer for dynamic content within that document.
    const newObserver = new MutationObserver((mutations) => {
      (doc.defaultView || window).requestAnimationFrame(() => {
        // Use iframe's rAF if available
        for (const mutation of mutations) {
          if (mutation.type === "childList") {
            processNewlyAddedNodes(mutation.addedNodes);
          } else if (mutation.type === "attributes") {
            if (mutation.target) {
              applyBlackModeToTree(mutation.target);
            }
          }
        }
      });
    });

    newObserver.observe(doc.documentElement, {
      childList: true,
      subtree: true,
      attributes: true,
      attributeFilter: ["class", "style"],
    });

    // When the iframe's window unloads, disconnect the observer.
    doc.defaultView?.addEventListener(
      "unload",
      () => {
        newObserver.disconnect();
        console.log(
          "Global Black: Cleaned up observer for document:",
          doc.location?.href,
        );
      },
      { once: true },
    );
  }

  /**
   * Finds and processes embeddable elements like iframes, frames, and objects.
   * @param {HTMLElement} element - The embeddable element to process.
   */
  function processEmbed(element) {
    if (element.dataset.globalBlackEmbedProcessed) {
      return;
    }
    element.dataset.globalBlackEmbedProcessed = "true";

    const setup = () => {
      try {
        const contentDoc = element.contentDocument;
        if (contentDoc) {
          applyThemeToDocument(contentDoc);
        }
      } catch (e) {
        console.warn(
          "Global Black: Could not access embed content. It may be cross-origin.",
          element,
        );
      }
    };

    try {
      const contentDoc = element.contentDocument;
      if (contentDoc && contentDoc.readyState === "complete") {
        setup();
      } else {
        element.addEventListener("load", setup, { once: true });
      }
    } catch (e) {
      console.warn(
        "Global Black: Could not access embed on initial check. It may be cross-origin.",
        element,
      );
    }
  }

  /**
   * Processes a list of newly added DOM nodes and their descendants.
   * This function is typically called by the MutationObserver.
   * @param {NodeList} nodes - A list of nodes that have been added to the DOM.
   */
  function processNewlyAddedNodes(nodes) {
    nodes.forEach((node) => {
      applyBlackModeToTree(node);

      if (node.nodeType === 1) {
        const tagName = node.tagName.toUpperCase();
        if (["IFRAME", "FRAME", "EMBED", "OBJECT"].includes(tagName)) {
          processEmbed(node);
        }
        node
          .querySelectorAll?.("iframe, frame, embed, object")
          .forEach(processEmbed);
      }
    });
  }

  function runFullConversion() {
    console.log("Global Black: Running full page conversion...");
    applyBlackModeToTree(document.documentElement);
    document
      .querySelectorAll("iframe, frame, embed, object")
      .forEach(processEmbed);
  }

  // --- OBSERVER FOR DYNAMIC CONTENT ---
  // This is the key to handling modern, dynamic websites.
  const observer = new MutationObserver((mutations) => {
    // Use requestAnimationFrame to batch all mutations that happen in a single frame.
    // This prevents performance issues and ensures the script doesn't miss anything
    // on pages that add many elements at once.
    window.requestAnimationFrame(() => {
      for (const mutation of mutations) {
        if (mutation.type === "childList") {
          // When new nodes are added, process them and all their children.
          processNewlyAddedNodes(mutation.addedNodes);
        } else if (mutation.type === "attributes") {
          // If an element's class or style changes, its color might have changed.
          // Re-run the process on that single element AND ITS DESCENDANTS.
          // This is crucial because a class/style change on a parent can affect children's computed styles.
          if (mutation.target) {
            applyBlackModeToTree(mutation.target); // Changed from processElement
          }
        }
      }
    });
  });

  // --- INITIALIZATION ---
  // The main styles are already injected. Now we wait for the DOM to be ready for deep traversal.
  if (document.readyState === "loading") {
    document.addEventListener("DOMContentLoaded", runFullConversion, {
      once: true,
    });
  } else {
    // If the script is injected after the page is loaded (e.g., via console).
    runFullConversion();
  }

  // --- ADDED: Re-run the conversion after all resources have loaded ---
  // This catches elements that are styled or loaded by JS after DOMContentLoaded.
  window.addEventListener("load", runFullConversion);

  // Start observing for changes after the initial conversion.
  observer.observe(document.documentElement, {
    childList: true,
    subtree: true,
    attributes: true,
    attributeFilter: ["class", "style"], // Only watch for attributes that are likely to affect appearance.
  });

  // Clean up when the user navigates away or closes the tab.
  window.addEventListener("unload", () => {
    if (observer) {
      observer.disconnect();
    }
    // Also remove the new load listener
    window.removeEventListener("load", runFullConversion);

    if (tempDiv && tempDiv.parentNode) {
      tempDiv.parentNode.removeChild(tempDiv);
    }
    if (style && style.parentNode) {
      style.parentNode.removeChild(style);
    }
    console.log("Global Black: Cleaned up and disconnected.");
  });
})();