Google Apps Dark Mode

Hybrid dark mode across Google's web apps. Auto-detects native dark theme per page and falls back to whole-page filter inversion when native dark is unavailable or absent on the active account.

이 스크립트를 설치하려면 Tampermonkey, Greasemonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey와 같은 확장 프로그램을 설치해야 합니다.

이 스크립트를 설치하려면 Tampermonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey 또는 Userscripts와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 유저 스크립트 관리자 확장 프로그램이 필요합니다.

(이미 유저 스크립트 관리자가 설치되어 있습니다. 설치를 진행합니다!)

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

(이미 유저 스타일 관리자가 설치되어 있습니다. 설치를 진행합니다!)

// ==UserScript==
// @name         Google Apps Dark Mode
// @namespace    https://github.com/johnneerdael/darkmode
// @version      0.3.2
// @description  Hybrid dark mode across Google's web apps. Auto-detects native dark theme per page and falls back to whole-page filter inversion when native dark is unavailable or absent on the active account.
// @author       John Meerdael
// @license MIT
// @match        https://mail.google.com/*
// @match        https://calendar.google.com/*
// @match        https://drive.google.com/*
// @match        https://docs.google.com/*
// @match        https://sheets.google.com/*
// @match        https://script.google.com/*
// @match        https://keep.google.com/*
// @match        https://meet.google.com/*
// @match        https://chat.google.com/*
// @match        https://voice.google.com/*
// @match        https://sites.google.com/*
// @match        https://contacts.google.com/*
// @match        https://photos.google.com/*
// @match        https://classroom.google.com/*
// @match        https://translate.google.com/*
// @match        https://admin.google.com/*
// @match        https://gemini.google.com/*
// @match        https://aistudio.google.com/*
// @match        https://console.cloud.google.com/*
// @match        https://console.firebase.google.com/*
// @match        https://lookerstudio.google.com/*
// @match        https://analytics.google.com/*
// @match        https://trends.google.com/*
// @match        https://scholar.google.com/*
// @match        https://news.google.com/*
// @match        https://groups.google.com/*
// @match        https://ads.google.com/*
// @match        https://adsense.google.com/*
// @match        https://merchants.google.com/*
// @match        https://search.google.com/search-console*
// @match        https://www.google.com/search*
// @grant        GM_addStyle
// @run-at       document-start
// ==/UserScript==

(function () {
  'use strict';

  // ───────────────────────────────────────────────────────────
  // Strategy
  //
  // Patch mode (Gmail, Calendar, Drive):
  //   When Google's native dark theme is enabled on the active account,
  //   small CSS patches fill gaps (toasts, dialogs, settings panes,
  //   Gemini sidebars). Brand colors preserved.
  //
  // Filter mode (everything else, plus patch-mode apps when native dark
  //   is NOT enabled):
  //   Apply `filter: invert(1) hue-rotate(180deg)` to <html>, re-invert
  //   media (img/video/iframe/embed/svg image) so photos appear normal.
  //   Catches every surface, including canvas-rendered content. Trade-
  //   off: Google brand colors hue-shift.
  //
  // Auto-detection: for patch-mode hosts, the script reads the body's
  // computed background color after first paint. Light → filter mode.
  // Dark → patch mode. Detection is per-page-load, so different Google
  // accounts (with different theme settings) each get the right mode.
  // ───────────────────────────────────────────────────────────

  // ───────────────────────────────────────────────────────────
  // Palette (used only by patch-mode apps).
  // ───────────────────────────────────────────────────────────
  const PALETTE = `
    :root {
      --bg:          #1a1a1a;
      --surface:     #242424;
      --surface-2:   #2d2d2d;
      --border:      #3a3a3a;
      --text:        #e8e8e8;
      --text-muted:  #a8a8a8;
      --accent:      #4a9eff;
      --danger:      #ff6b6b;
      --success:     #5dd47e;
    }
  `;

  // ───────────────────────────────────────────────────────────
  // Patch base — common gap-patches for native-dark apps.
  // ───────────────────────────────────────────────────────────
  const PATCH_BASE = `
    /* Inline-styled white backgrounds — Google sets these in many places */
    [style*="background-color: rgb(255, 255, 255)"],
    [style*="background-color: #ffffff"],
    [style*="background-color: #fff;"],
    [style*="background: rgb(255, 255, 255)"],
    [style*="background: #ffffff"] {
      background-color: var(--surface) !important;
    }

    /* Side panels (Gemini "Ask Gemini", Smart Compose suggestions, etc.) */
    [aria-label*="Gemini"][role="region"],
    [aria-label*="Gemini" i][role="complementary"],
    [aria-label*="Side panel"],
    [aria-label*="side panel"],
    [data-side-panel-id],
    [role="complementary"] {
      background-color: var(--surface) !important;
      color: var(--text) !important;
      border-color: var(--border) !important;
    }

    /* Generic dialog backgrounds */
    [role="dialog"][style*="background"]:not([style*="rgba"]) {
      background-color: var(--surface) !important;
      color: var(--text) !important;
    }
    [role="dialog"] [role="textbox"],
    [role="dialog"] input[type="text"],
    [role="dialog"] input[type="email"],
    [role="dialog"] textarea {
      background-color: var(--surface-2) !important;
      color: var(--text) !important;
      border-color: var(--border) !important;
    }
  `;

  // ───────────────────────────────────────────────────────────
  // Filter base — whole-page inversion for filter-mode apps.
  //
  // Applied to <html>: every pixel rendered by every element gets
  // inverted, including canvas-drawn content. Re-invert media so
  // photos/videos/iframes appear normal.
  //
  // Note: Google brand colors will hue-shift (blue → orange-ish).
  // This is the cost of catching every surface without per-class
  // maintenance.
  // ───────────────────────────────────────────────────────────
  const FILTER_BASE = `
    html {
      filter: invert(1) hue-rotate(180deg) !important;
      background-color: white !important;
    }
    /* Re-invert media so it appears at original colors */
    img,
    video,
    iframe,
    embed,
    object,
    /* SVG <image> elements (Slides/Docs render embedded photos this way) */
    image,
    svg image,
    [style*="background-image"]:not(html) {
      filter: invert(1) hue-rotate(180deg) !important;
    }
  `;

  // ───────────────────────────────────────────────────────────
  // Per-app modules.
  //
  // Patch-mode modules (gmail/calendar/drive): targeted gap fixes.
  // Filter-mode modules: empty by default — the whole-page filter
  //   does the work. Add overrides here only when you need to
  //   tweak something that filter inversion gets wrong (e.g.
  //   re-invert a specific element to keep its true colors).
  // ───────────────────────────────────────────────────────────
  const MODULES = {
    gmail: `
      /* Settings → Themes screen — ships in light mode regardless of theme choice */
      [aria-label*="Settings"] .Bu .nH,
      .nH[role="dialog"] {
        background-color: var(--surface) !important;
        color: var(--text) !important;
      }

      /* Toast notifications and undo bar */
      .b8.UC,
      .vh,
      .bAq {
        background-color: var(--surface-2) !important;
        color: var(--text) !important;
        border: 1px solid var(--border) !important;
      }

      /* Confirmation modals (Discard draft, Delete forever, etc.) */
      .Kj-JD,
      .Kj-JD-Jz {
        background-color: var(--surface) !important;
        color: var(--text) !important;
      }
      .Kj-JD .Kj-JD-K7 { color: var(--text) !important; }

      /* Add-on side panel (right rail iframes' container chrome) */
      .bvE,
      .brC-bvE-bsf {
        background-color: var(--surface) !important;
      }

      /* Generic inline-styled white backgrounds in chrome (last resort) */
      .nH[style*="background-color: rgb(255, 255, 255)"],
      .nH[style*="background-color: #ffffff"] {
        background-color: var(--surface) !important;
      }
    `,
    calendar: `
      /* Event detail popovers ship light styling in some variants */
      [role="dialog"][aria-label*="Event"],
      .RGOEzd,
      .vGzcOe {
        background-color: var(--surface) !important;
        color: var(--text) !important;
      }
      [role="dialog"] [aria-label*="Event"] * { color: inherit; }

      /* "Find a time" / scheduling assistant */
      .nBzpcb,
      .QQYuzf {
        background-color: var(--surface) !important;
        color: var(--text) !important;
      }
      .nBzpcb .UPqyyc { background-color: var(--surface-2) !important; }

      /* Settings sub-pages */
      .yDSiEf,
      .HEcCRb {
        background-color: var(--bg) !important;
        color: var(--text) !important;
      }
      .yDSiEf input,
      .yDSiEf select {
        background-color: var(--surface) !important;
        color: var(--text) !important;
        border-color: var(--border) !important;
      }
    `,
    drive: `
      /* Right rail: Details and Activity panel */
      [aria-label*="Details"][role="region"],
      [aria-label*="Activity"][role="region"],
      .a-Nb-Hz,
      .a-Hb-Nb {
        background-color: var(--surface) !important;
        color: var(--text) !important;
      }
      [aria-label*="Details"] *,
      [aria-label*="Activity"] * { color: inherit; }

      /* File preview overlay chrome */
      .ndfHFb-c4YZDc-Wrql6b,
      .ndfHFb-c4YZDc {
        background-color: var(--bg) !important;
        color: var(--text) !important;
      }

      /* Share dialog */
      [role="dialog"][aria-label*="Share"],
      [role="dialog"][aria-label*="hare"] {
        background-color: var(--surface) !important;
        color: var(--text) !important;
      }
      [role="dialog"][aria-label*="hare"] input,
      [role="dialog"][aria-label*="hare"] textarea {
        background-color: var(--surface-2) !important;
        color: var(--text) !important;
        border-color: var(--border) !important;
      }

      /* Move-to dialog */
      [role="dialog"][aria-label*="Move"],
      [role="dialog"][aria-label*="move"] {
        background-color: var(--surface) !important;
        color: var(--text) !important;
      }

      /* Toast notifications */
      .a-b-K-K-S,
      .a-rb-D-Kf {
        background-color: var(--surface-2) !important;
        color: var(--text) !important;
        border: 1px solid var(--border) !important;
      }
    `,
    // Filter-mode modules — empty unless we need targeted overrides.
    docs: ``,
    sheets: ``,
    slides: ``,
    forms: ``,
    appsScript: ``,
  };

  // ───────────────────────────────────────────────────────────
  // Dispatcher
  //
  // Three classifications:
  //   - 'filter': force filter mode (canvas-rendered apps, no native
  //     dark to detect — Docs, Sheets, Slides, Forms, Apps Script)
  //   - 'patch': run auto-detect (light → filter, dark → patch CSS).
  //     Used for every other supported Google host.
  //   - 'none': don't theme this URL.
  // ───────────────────────────────────────────────────────────

  // Hosts whose body bg should be auto-detected to choose mode.
  // Most have native dark themes (some on, some off per account).
  const AUTO_HOSTS = new Set([
    'mail.google.com',
    'calendar.google.com',
    'drive.google.com',
    'keep.google.com',
    'meet.google.com',
    'chat.google.com',
    'voice.google.com',
    'sites.google.com',
    'contacts.google.com',
    'photos.google.com',
    'classroom.google.com',
    'translate.google.com',
    'admin.google.com',
    'gemini.google.com',
    'aistudio.google.com',
    'console.cloud.google.com',
    'console.firebase.google.com',
    'lookerstudio.google.com',
    'analytics.google.com',
    'trends.google.com',
    'scholar.google.com',
    'news.google.com',
    'groups.google.com',
    'ads.google.com',
    'adsense.google.com',
    'merchants.google.com',
  ]);

  // Per-app patch CSS (only for hosts where we've authored gap fixes).
  // Other hosts get PATCH_BASE only when auto-detect picks patch mode.
  const PER_APP_PATCH = {
    'mail.google.com':     MODULES.gmail,
    'calendar.google.com': MODULES.calendar,
    'drive.google.com':    MODULES.drive,
  };

  function classify() {
    const host = location.hostname;
    const path = location.pathname;

    // Forced-filter hosts (canvas rendering, can't detect from body bg)
    if (host === 'sheets.google.com')  return { mode: 'filter' };
    if (host === 'script.google.com')  return { mode: 'filter' };
    if (host === 'docs.google.com') {
      if (path.startsWith('/document') ||
          path.startsWith('/spreadsheets') ||
          path.startsWith('/presentation') ||
          path.startsWith('/forms') ||
          path.startsWith('/videos') ||
          path.startsWith('/drawings')) {
        return { mode: 'filter' };
      }
      return { mode: 'none' };
    }

    // Path-scoped auto-detect targets
    if (host === 'search.google.com' && path.startsWith('/search-console')) {
      return { mode: 'patch' };
    }
    if (host === 'www.google.com' && path.startsWith('/search')) {
      return { mode: 'patch' };
    }

    // Host-scoped auto-detect
    if (AUTO_HOSTS.has(host)) {
      return { mode: 'patch', module: PER_APP_PATCH[host] || '' };
    }

    return { mode: 'none' };
  }

  // ───────────────────────────────────────────────────────────
  // Auto-detect: read body background luminance to decide whether
  // Google's native dark theme is active on this page.
  //
  // CSS filter is a visual effect — getComputedStyle returns the
  // underlying value, not the filter-affected one — so reading the
  // body's natural background color works reliably.
  // ───────────────────────────────────────────────────────────
  function isPageLight() {
    if (!document.body) return false;
    // Walk html → body → top-level wrappers looking for the first
    // ancestor with an explicit (non-transparent) bg color.
    const candidates = [document.documentElement, document.body];
    const wrappers = document.querySelectorAll('body > div');
    for (let i = 0; i < Math.min(wrappers.length, 5); i++) {
      candidates.push(wrappers[i]);
    }
    for (const el of candidates) {
      const bg = getComputedStyle(el).backgroundColor;
      const m = bg.match(/(\d+),\s*(\d+),\s*(\d+)(?:,\s*([\d.]+))?/);
      if (!m) continue;
      const alpha = m[4] !== undefined ? parseFloat(m[4]) : 1;
      if (alpha < 0.1) continue;
      const luminance = (0.299 * +m[1] + 0.587 * +m[2] + 0.114 * +m[3]) / 255;
      return luminance > 0.5;
    }
    // No element has an explicit bg color → browser default white shows
    // through. Treat as light so filter mode kicks in.
    return true;
  }

  function whenBodyReady(cb) {
    if (document.body) {
      // Wait one frame so initial styles apply before we read.
      requestAnimationFrame(() => requestAnimationFrame(cb));
    } else {
      requestAnimationFrame(() => whenBodyReady(cb));
    }
  }

  function applyFilter() {
    GM_addStyle(FILTER_BASE);
  }
  function applyPatch(perApp) {
    GM_addStyle(PALETTE);
    GM_addStyle(PATCH_BASE);
    if (perApp) GM_addStyle(perApp);
  }

  function dispatch() {
    const { mode, module: perApp } = classify();
    if (mode === 'none') return;

    if (mode === 'filter') {
      applyFilter();
      if (perApp) GM_addStyle(perApp);
      return;
    }

    // mode === 'patch': auto-detect Google's native theme.
    // Light page → fall back to filter mode (per-app patch CSS not applied).
    // Dark page → apply patch CSS as designed.
    whenBodyReady(() => {
      if (isPageLight()) {
        applyFilter();
      } else {
        applyPatch(perApp);
      }
    });
  }

  dispatch();
})();