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