docs: Add dark mode (#17940)

Danilo Leal created

Closes https://github.com/zed-industries/zed/issues/17911

This PR enables dark mode on the documentation. This is done without any
special plugins, just pure JavaScript and CSS variables. I may open
fast-follow PRs to fine-tune design and code details that haven't been
super polished yet. For example, when switching to dark mode, the
`class` attribute on the `html` tag would change immediately, whereas
other attributes such as `data-theme` and `data-color-scheme` would
require a full refresh. This seems to be resolved, but not 100%
confident yet.

---

Release Notes:

- Enabled dark mode on the documentation

Change summary

docs/theme/css/chrome.css    |  47 +++++++++-------
docs/theme/css/general.css   |  14 ++--
docs/theme/css/variables.css | 105 +++++++++++++++++++++++++++++++++++--
docs/theme/index.hbs         |  38 ++++++++++---
docs/theme/page-toc.css      |   2 
docs/theme/plugins.css       |   2 
docs/theme/plugins.js        |  62 ++++++++++++++++++++++
7 files changed, 224 insertions(+), 46 deletions(-)

Detailed changes

docs/theme/css/chrome.css 🔗

@@ -3,7 +3,7 @@
 @import "variables.css";
 
 html {
-  background-color: rgb(246, 245, 240);
+  background-color: var(--bg);
   scrollbar-color: var(--scrollbar) var(--bg);
 }
 #searchresults a,
@@ -58,7 +58,7 @@ a > .hljs {
   height: var(--menu-bar-height);
 }
 #menu-bar.bordered {
-  border-block-end-color: var(--table-border-color);
+  border-block-end-color: var(--divider);
 }
 #menu-bar i,
 #menu-bar .icon-button {
@@ -73,7 +73,7 @@ a > .hljs {
   transition: color 0.5s;
 }
 #menu-bar .icon-button:hover {
-  background-color: hsl(219, 93%, 42%, 0.15);
+  background-color: var(--icon-btn-bg-hover);
 }
 
 @media only screen and (max-width: 420px) {
@@ -116,6 +116,7 @@ a > .hljs {
   align-items: center;
   flex: 1;
   overflow: hidden;
+  filter: var(--logo-brightness);
 }
 .js .menu-title {
   cursor: pointer;
@@ -249,9 +250,10 @@ a:hover > .hljs {
 }
 
 pre {
-  background-color: white;
-  border: 1px rgba(8, 76, 207, 0.3) solid;
-  box-shadow: rgba(8, 76, 207, 0.07) 4px 4px 0px 0px;
+  background-color: var(--pre-bg);
+  border: 1px solid;
+  border-color: var(--pre-border);
+  box-shadow: var(--pre-shadow) 4px 4px 0px 0px;
   position: relative;
 }
 pre > .hljs {
@@ -445,7 +447,8 @@ ul#searchresults span.teaser em {
   overscroll-behavior-y: contain;
   background-color: var(--sidebar-bg);
   color: var(--sidebar-fg);
-  border-right: 1px solid hsl(219, 93%, 42%, 0.15);
+  border-right: 1px solid;
+  border-color: var(--divider);
 }
 [dir="rtl"] .sidebar {
   left: unset;
@@ -606,7 +609,7 @@ ul#searchresults span.teaser em {
   margin: 5px 0px;
 }
 .chapter .spacer {
-  background-color: var(--sidebar-spacer);
+  background-color: var(--divider);
 }
 
 @media (-moz-touch-enabled: 1), (pointer: coarse) {
@@ -628,11 +631,11 @@ ul#searchresults span.teaser em {
 
 .theme-popup {
   position: absolute;
-  left: 10px;
-  top: var(--menu-bar-height);
+  left: 32px;
+  top: calc(var(--menu-bar-height) - 12px);
   z-index: 1000;
   border-radius: 4px;
-  font-size: 0.7em;
+  font-size: 1.4rem;
   color: var(--fg);
   background: var(--theme-popup-bg);
   border: 1px solid var(--theme-popup-border);
@@ -654,7 +657,7 @@ ul#searchresults span.teaser em {
   width: 100%;
   border: 0;
   margin: 0;
-  padding: 2px 20px;
+  padding: 2px 24px;
   line-height: 25px;
   white-space: nowrap;
   text-align: start;
@@ -662,32 +665,36 @@ ul#searchresults span.teaser em {
   color: inherit;
   background: inherit;
   font-size: inherit;
+  font-family: inherit;
 }
 .theme-popup .theme:hover {
   background-color: var(--theme-hover);
 }
 
 .theme-selected::before {
+  font-family: Arial, Helvetica, sans-serif;
+  text-align: center;
   display: inline-block;
   content: "✓";
-  margin-inline-start: -14px;
-  width: 14px;
+  margin-inline-start: -20px;
+  width: 20px;
 }
 
 .download-button {
-  background: hsl(220, 60%, 95%);
-  color: hsl(220, 60%, 30%);
+  background: var(--download-btn-bg);
+  color: var(--download-btn-color);
   padding: 4px 8px;
-  border: 1px solid hsla(220, 60%, 40%, 0.2);
+  border: 1px solid;
+  border-color: var(--download-btn-border);
   font-size: 1.4rem;
   border-radius: 4px;
-  box-shadow: hsla(220, 40%, 60%, 0.1) 0px -2px 0px 0px inset;
+  box-shadow: var(--download-btn-shadow) 0px -2px 0px 0px inset;
   transition: 100ms;
   transition-property: box-shadow, border-color, background-color;
 }
 
 .download-button:hover {
-  background: hsl(220, 60%, 93%);
-  border-color: hsla(220, 60%, 50%, 0.2);
+  background: var(--download-btn-bg);
+  border-color: var(--download-btn-border-hover);
   box-shadow: none;
 }

docs/theme/css/general.css 🔗

@@ -174,10 +174,10 @@ h6:target::before {
 }
 .content a {
   text-decoration: underline;
-  text-decoration-color: hsl(219, 93%, 42%, 0.2);
+  text-decoration-color: var(--link-line-decoration);
 }
 .content a:hover {
-  text-decoration-color: hsl(219, 93%, 42%, 0.5);
+  text-decoration-color: var(--link-line-decoration-hover);
 }
 .content img,
 .content video {
@@ -219,7 +219,7 @@ table thead td {
 }
 table thead th {
   padding: 6px 12px;
-  color: #000;
+  color: var(--full-contrast);
   text-align: left;
   border: 1px var(--table-border-color) solid;
 }
@@ -235,7 +235,7 @@ blockquote {
   margin: auto;
   margin-top: 1rem;
   padding: 1rem 1.25rem;
-  color: #000;
+  color: var(--full-contrast);
   background-color: var(--quote-bg);
   border: 1px solid var(--quote-border);
 }
@@ -315,7 +315,7 @@ kbd {
   font-size: 1.4rem;
   margin: 0.5em 0;
   border-bottom: 1px solid;
-  border-color: var(--border-light);
+  border-color: var(--divider);
 }
 .footnote-definition p {
   display: inline;
@@ -356,6 +356,6 @@ kbd {
 }
 
 code.hljs {
-  color: hsl(221, 13%, 10%) !important;
-  background-color: hsla(221, 93%, 42%, 0.1);
+  color: var(--code-text) !important;
+  background-color: var(--code-bg);
 }

docs/theme/css/variables.css 🔗

@@ -1,6 +1,10 @@
 /* Globals */
 
 :root {
+  --color-scheme: light;
+
+  --logo-brightness: brightness(1);
+
   --sidebar-width: 300px;
   --sidebar-resize-indicator-width: 0px;
   --sidebar-resize-indicator-space: 2px;
@@ -24,18 +28,30 @@
 
   --sidebar-fg: hsl(0, 0%, 0%);
   --sidebar-non-existant: #aaaaaa;
-  --sidebar-active: hsl(219, 93%, 42%);
-  --sidebar-active-bg: hsl(219, 93%, 42%, 0.1);
-  --sidebar-spacer: #f4f4f4;
+  --sidebar-active: hsl(220, 93%, 42%);
+  --sidebar-active-bg: hsl(220, 93%, 42%, 0.1);
 
+  --divider: hsl(220, 93%, 42%, 0.15);
   --scrollbar: #8f8f8f;
 
   --icons: #747474;
   --icons-hover: #000000;
+  --icon-btn-bg-hover: hsl(220, 93%, 42%, 0.15);
 
-  --links: rgb(8, 76, 207);
+  --links: hsl(220, 92%, 42%);
+  --link-line-decoration: hsl(220, 93%, 42%, 0.2);
+  --link-line-decoration-hover: hsl(220, 93%, 42%, 0.5);
+
+  --full-contrast: #000;
 
   --inline-code-color: #301900;
+  --code-text: hsl(220, 13%, 10%);
+  --code-bg: hsl(220, 93%, 42%, 0.1);
+  --keybinding-bg: hsl(0, 0%, 94%);
+
+  --pre-bg: #fff;
+  --pre-border: hsla(220, 93%, 42%, 0.3);
+  --pre-shadow: hsla(220, 93%, 42%, 0.07);
 
   --theme-popup-bg: #fafafa;
   --theme-popup-border: #cccccc;
@@ -48,9 +64,9 @@
   --warning-bg: hsl(42, 100%, 60%, 0.1);
   --warning-icon: hsl(42, 100%, 30%);
 
-  --table-header-bg: hsl(219, 50%, 90%, 0.4);
-  --table-border-color: hsl(219, 93%, 42%, 0.15);
-  --table-alternate-bg: hsl(219, 10%, 90%, 0.4);
+  --table-header-bg: hsl(220, 50%, 90%, 0.4);
+  --table-border-color: hsl(220, 93%, 42%, 0.15);
+  --table-alternate-bg: hsl(220, 10%, 90%, 0.4);
 
   --searchbar-border-color: #aaa;
   --searchbar-bg: #fafafa;
@@ -61,5 +77,78 @@
   --searchresults-li-bg: #e4f2fe;
   --search-mark-bg: #a2cff5;
 
-  --color-scheme: light;
+  --download-btn-bg: hsl(220, 60%, 95%);
+  --download-btn-bg-hover: hsl(220, 60%, 93%);
+  --download-btn-color: hsl(220, 60%, 30%);
+  --download-btn-border: hsla(220, 60%, 40%, 0.2);
+  --download-btn-border-hover: hsla(220, 60%, 50%, 0.2);
+  --download-btn-shadow: hsla(220, 40%, 60%, 0.1);
+}
+
+.dark {
+  --color-scheme: dark;
+
+  --logo-brightness: brightness(2);
+
+  --bg: hsl(220, 13%, 10%);
+  --fg: hsl(220, 14%, 70%);
+  --title-color: hsl(220, 92%, 80%);
+
+  --border: hsl(220, 13%, 20%);
+  --border-light: hsl(220, 13%, 90%);
+  --border-hover: hsl(220, 13%, 40%);
+
+  --sidebar-bg: hsl(220, 13%, 10%);
+  --sidebar-fg: hsl(220, 14%, 71%);
+  --sidebar-non-existant: #505254;
+  --sidebar-active: hsl(220, 92%, 75%);
+  --sidebar-active-bg: hsl(220, 93%, 42%, 0.25);
+
+  --divider: hsl(220, 13%, 20%);
+  --scrollbar: hsl(220, 13%, 30%);
+
+  --icons: hsl(220, 14%, 71%);
+  --icons-hover: hsl(220, 14%, 90%);
+  --icon-btn-bg-hover: hsl(220, 93%, 42%, 0.4);
+
+  --links: hsl(220, 93%, 70%);
+  --link-line-decoration: hsl(220, 92%, 80%, 0.4);
+  --link-line-decoration-hover: hsl(220, 92%, 80%, 0.8);
+  --full-contrast: #fff;
+
+  --inline-code-color: hsl(40, 100%, 80%);
+  --code-text: hsl(220, 13%, 95%);
+  --code-bg: hsl(220, 93%, 50%, 0.2);
+  --keybinding-bg: hsl(0, 0%, 12%);
+
+  --pre-bg: hsl(220, 13%, 5%);
+  --pre-border: hsla(220, 93%, 70%, 0.3);
+  --pre-shadow: hsla(220, 93%, 70%, 0.1);
+
+  --theme-popup-bg: hsl(220, 13%, 15%);
+  --theme-popup-border: hsl(220, 13%, 20%);
+  --theme-hover: hsl(220, 13%, 25%);
+
+  --quote-bg: hsl(220, 13%, 25%, 0.4);
+  --quote-border: hsl(220, 13%, 32%, 0.5);
+
+  --table-border-color: hsl(220, 13%, 30%, 0.5);
+  --table-header-bg: hsl(220, 13%, 25%, 0.5);
+  --table-alternate-bg: hsl(220, 13%, 20%, 0.4);
+
+  --searchbar-border-color: hsl(220, 13%, 30%);
+  --searchbar-bg: hsl(220, 13%, 22%, 0.5);
+  --searchbar-fg: hsl(220, 14%, 71%);
+  --searchbar-shadow-color: hsl(220, 13%, 15%);
+  --searchresults-header-fg: hsl(220, 14%, 60%);
+  --searchresults-border-color: hsl(220, 13%, 30%);
+  --searchresults-li-bg: hsl(220, 13%, 25%);
+  --search-mark-bg: hsl(220, 93%, 60%);
+
+  --download-btn-bg: hsl(220, 90%, 90%, 0.1);
+  --download-btn-bg-hover: hsl(220, 90%, 50%, 0.2);
+  --download-btn-color: hsl(220, 90%, 95%);
+  --download-btn-border: hsla(220, 90%, 80%, 0.2);
+  --download-btn-border-hover: hsla(220, 90%, 80%, 0.4);
+  --download-btn-shadow: hsla(220, 50%, 60%, 0.15);
 }

docs/theme/index.hbs 🔗

@@ -1,5 +1,5 @@
 <!DOCTYPE HTML>
-<html lang="{{ language }}" class="{{ default_theme }}" dir="{{ text_direction }}">
+<html lang="{{ language }}" data-theme="{{ default_theme }}" data-color-scheme="{{ default_theme }}" dir="{{ text_direction }}">
     <head>
         <!-- Book generated using mdBook -->
         <meta charset="UTF-8">
@@ -56,13 +56,15 @@
             var default_theme = window.matchMedia("(prefers-color-scheme: dark)").matches ? "{{ preferred_dark_theme }}" : "{{ default_theme }}";
         </script>
 
-        <!-- Set the theme before any content is loaded, prevents flash -->
+        <!-- Support dark mode -->
         <script>
             var theme;
             try { theme = localStorage.getItem('mdbook-theme'); } catch(e) { }
-            if (theme === null || theme === undefined) { theme = default_theme; }
+            if (theme === null || theme === undefined) {
+                theme = window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light";
+            }
             var html = document.querySelector('html');
-            html.classList.remove('{{ default_theme }}')
+            html.classList.remove('light', 'dark')
             html.classList.add(theme);
             var body = document.querySelector('body');
             body.classList.remove('no-js')
@@ -124,19 +126,19 @@
                 <div id="menu-bar-hover-placeholder"></div>
                 <div id="menu-bar" class="menu-bar sticky">
                     <div class="left-buttons">
+
                         <label id="sidebar-toggle" class="icon-button" for="sidebar-toggle-anchor" title="Toggle Table of Contents" aria-label="Toggle Table of Contents" aria-controls="sidebar">
                             <i class="fa fa-bars"></i>
                         </label>
-                        <button style="display: none;" id="theme-toggle" class="icon-button" type="button" title="Change theme" aria-label="Change theme" aria-haspopup="true" aria-expanded="false" aria-controls="theme-list">
+
+                        <button id="theme-toggle" class="icon-button" type="button" title="Change theme" aria-label="Change theme" aria-haspopup="true" aria-expanded="false" aria-controls="theme-list">
                             <i class="fa fa-paint-brush"></i>
                         </button>
                         <ul id="theme-list" class="theme-popup" aria-label="Themes" role="menu">
                             <li role="none"><button role="menuitem" class="theme" id="light">Light</button></li>
-                            <li role="none"><button role="menuitem" class="theme" id="rust">Rust</button></li>
-                            <li role="none"><button role="menuitem" class="theme" id="coal">Coal</button></li>
-                            <li role="none"><button role="menuitem" class="theme" id="navy">Navy</button></li>
-                            <li role="none"><button role="menuitem" class="theme" id="ayu">Ayu</button></li>
+                            <li role="none"><button role="menuitem" class="theme" id="dark">Dark</button></li>
                         </ul>
+
                         {{#if search_enabled}}
                         <button id="search-toggle" class="icon-button" type="button" title="Search. (Shortkey: s)" aria-label="Toggle Searchbar" aria-expanded="false" aria-keyshortcuts="S" aria-controls="searchbar">
                             <i class="fa fa-search"></i>
@@ -274,6 +276,24 @@
         {{/if}}
         {{/if}}
 
+        <script>
+          (function() {
+            var theme = localStorage.getItem('mdbook-theme');
+            var html = document.querySelector('html');
+            if (theme) {
+              html.setAttribute('data-theme', theme);
+              html.setAttribute('data-color-scheme', theme);
+              html.className = theme;
+            } else {
+              var systemPreference = window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light";
+              html.setAttribute('data-theme', systemPreference);
+              html.setAttribute('data-color-scheme', systemPreference);
+              html.className = systemPreference;
+            }
+          })();
+        </script>
+
+
     </div>
     </body>
 </html>

docs/theme/page-toc.css 🔗

@@ -74,6 +74,6 @@
     margin-bottom: 12px;
     padding-left: 12px;
     font-size: 1.4rem;
-    color: #000;
+    color: var(--full-contrast);
   }
 }

docs/theme/plugins.css 🔗

@@ -1,5 +1,5 @@
 kbd.keybinding {
-  background-color: #f0f0f0;
+  background-color: var(--keybinding-bg);
   padding: 2px 4px;
   border-radius: 3px;
   font-family: monospace;

docs/theme/plugins.js 🔗

@@ -48,3 +48,65 @@ console.log("Operating System:", os);
   // Start the process from the body
   walkDOM(document.body);
 })();
+
+function darkModeToggle() {
+  var html = document.documentElement;
+  var themeToggleButton = document.getElementById("theme-toggle");
+  var themePopup = document.getElementById("theme-list");
+  var themePopupButtons = themePopup.querySelectorAll("button");
+
+  function setTheme(theme) {
+    html.setAttribute("data-theme", theme);
+    html.setAttribute("data-color-scheme", theme);
+    html.className = theme;
+    localStorage.setItem("mdbook-theme", theme);
+
+    // Force a repaint to ensure the changes take effect in the client immediately
+    document.body.style.display = "none";
+    document.body.offsetHeight;
+    document.body.style.display = "";
+  }
+
+  themeToggleButton.addEventListener("click", function (event) {
+    event.preventDefault();
+    themePopup.style.display =
+      themePopup.style.display === "block" ? "none" : "block";
+  });
+
+  themePopupButtons.forEach(function (button) {
+    button.addEventListener("click", function () {
+      setTheme(this.id);
+      themePopup.style.display = "none";
+    });
+  });
+
+  document.addEventListener("click", function (event) {
+    if (
+      !themePopup.contains(event.target) &&
+      !themeToggleButton.contains(event.target)
+    ) {
+      themePopup.style.display = "none";
+    }
+  });
+
+  // Set initial theme
+  var currentTheme = localStorage.getItem("mdbook-theme");
+  if (currentTheme) {
+    setTheme(currentTheme);
+  } else {
+    // If no theme is set, use the system's preference
+    var systemPreference = window.matchMedia("(prefers-color-scheme: dark)")
+      .matches
+      ? "dark"
+      : "light";
+    setTheme(systemPreference);
+  }
+
+  // Listen for system's preference changes
+  const darkModeMediaQuery = window.matchMedia("(prefers-color-scheme: dark)");
+  darkModeMediaQuery.addEventListener("change", function (e) {
+    if (!localStorage.getItem("mdbook-theme")) {
+      setTheme(e.matches ? "dark" : "light");
+    }
+  });
+}