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.

Vous devrez installer une extension telle que Tampermonkey, Greasemonkey ou Violentmonkey pour installer ce script.

Vous devrez installer une extension telle que Tampermonkey pour installer ce script.

Vous devrez installer une extension telle que Tampermonkey ou Violentmonkey pour installer ce script.

Vous devrez installer une extension telle que Tampermonkey ou Userscripts pour installer ce script.

Vous devrez installer une extension telle que Tampermonkey pour installer ce script.

Vous devrez installer une extension de gestionnaire de script utilisateur pour installer ce script.

(J'ai déjà un gestionnaire de scripts utilisateur, laissez-moi l'installer !)

Vous devrez installer une extension telle que Stylus pour installer ce style.

Vous devrez installer une extension telle que Stylus pour installer ce style.

Vous devrez installer une extension telle que Stylus pour installer ce style.

Vous devrez installer une extension du gestionnaire de style pour utilisateur pour installer ce style.

Vous devrez installer une extension du gestionnaire de style pour utilisateur pour installer ce style.

Vous devrez installer une extension du gestionnaire de style pour utilisateur pour installer ce style.

(J'ai déjà un gestionnaire de style utilisateur, laissez-moi l'installer!)

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