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.

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.

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

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

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

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

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

// ==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();
})();