Add dark mode support to Gitiles

Implement CSS custom properties across core UI, markdown docs, and
syntax highlighting stylesheets. Add a zero-FOUC theme initialization
script in <head> and an interactive toggle in the header that persists
preference across sessions via localStorage and cookies.

Tested:
- Added comprehensive unit tests in ThemeTest covering all HTML routes.
- Ran all Gitiles servlet unit tests and downstream Gerrit HTTP tests.

Change-Id: I2b8bf5dacd986710a62b61df87550d776ca20290
diff --git a/java/com/google/gitiles/Renderer.java b/java/com/google/gitiles/Renderer.java
index dd08353..964063d 100644
--- a/java/com/google/gitiles/Renderer.java
+++ b/java/com/google/gitiles/Renderer.java
@@ -29,6 +29,9 @@
 import com.google.common.html.types.LegacyConversions;
 import com.google.common.io.ByteStreams;
 import com.google.common.net.HttpHeaders;
+import com.google.template.soy.data.SanitizedContent;
+import com.google.template.soy.data.SanitizedContent.ContentKind;
+import com.google.template.soy.data.SanitizedContents;
 import com.google.template.soy.jbcsrc.api.SoySauce;
 import java.io.File;
 import java.io.IOException;
@@ -55,6 +58,22 @@
   // Must match .streamingPlaceholder.
   private static final String PLACEHOLDER = "id=\"STREAMED-OUTPUT-BLOCK\"";
 
+  private static final SanitizedContent THEME_INIT_SCRIPT;
+  private static final SanitizedContent THEME_TOGGLE_SCRIPT;
+
+  static {
+    try {
+      THEME_INIT_SCRIPT =
+          SanitizedContents.fromResource(
+              Renderer.class, "static/theme-init.js", UTF_8, ContentKind.JS);
+      THEME_TOGGLE_SCRIPT =
+          SanitizedContents.fromResource(
+              Renderer.class, "static/theme-toggle.js", UTF_8, ContentKind.JS);
+    } catch (IOException e) {
+      throw new ExceptionInInitializerError(e);
+    }
+  }
+
   private static final ImmutableList<String> SOY_FILENAMES =
       ImmutableList.of(
           "BlameDetail.soy",
@@ -235,7 +254,9 @@
     ImmutableMap.Builder<String, Object> ij =
         ImmutableMap.<String, Object>builder()
             .put("staticUrls", staticUrls.build())
-            .put("SITE_TITLE", siteTitle);
+            .put("SITE_TITLE", siteTitle)
+            .put("THEME_INIT_SCRIPT", THEME_INIT_SCRIPT)
+            .put("THEME_TOGGLE_SCRIPT", THEME_TOGGLE_SCRIPT);
     Optional<String> nonce = req.map((r) -> (String) r.getAttribute("nonce"));
     if (nonce.isPresent()) {
       ij.put("csp_nonce", nonce.get());
diff --git a/javatests/com/google/gitiles/ThemeTest.java b/javatests/com/google/gitiles/ThemeTest.java
new file mode 100644
index 0000000..2be83c4
--- /dev/null
+++ b/javatests/com/google/gitiles/ThemeTest.java
@@ -0,0 +1,124 @@
+// Copyright (C) 2026 Google Inc. All Rights Reserved.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gitiles;
+
+import static com.google.common.truth.Truth.assertThat;
+import static javax.servlet.http.HttpServletResponse.SC_NOT_FOUND;
+
+import org.eclipse.jgit.internal.storage.dfs.DfsRepositoryDescription;
+import org.eclipse.jgit.internal.storage.dfs.InMemoryRepository;
+import org.eclipse.jgit.junit.TestRepository;
+import org.eclipse.jgit.revwalk.RevCommit;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.junit.runners.JUnit4;
+
+/** Comprehensive tests for dark mode theme rendering and toggle across all Gitiles HTML routes. */
+@RunWith(JUnit4.class)
+public class ThemeTest extends ServletTest {
+  private static final String REPO_NAME = "test-repo";
+
+  @Override
+  @Before
+  public void setUp() throws Exception {
+    repo = new TestRepository<>(new InMemoryRepository(new DfsRepositoryDescription(REPO_NAME)));
+    servlet = TestGitilesServlet.create(repo);
+  }
+
+  @Test
+  public void hostIndexIncludesThemeScriptAndToggle() throws Exception {
+    String html = buildHtml("/", false);
+    assertThemeElementsPresent(html);
+  }
+
+  @Test
+  public void repositoryIndexIncludesThemeScriptAndToggle() throws Exception {
+    String html = buildHtml("/" + REPO_NAME + "/", false);
+    assertThemeElementsPresent(html);
+  }
+
+  @Test
+  public void docPageIncludesThemeScriptAndToggle() throws Exception {
+    repo.branch("master").commit().add("README.md", "# Hello Gitiles\nDark mode test.").create();
+    String html = buildHtml("/" + REPO_NAME + "/+doc/master/README.md", false);
+    assertThemeElementsPresent(html);
+  }
+
+  @Test
+  public void commitDetailIncludesThemeScriptAndToggle() throws Exception {
+    RevCommit commit = repo.branch("master").commit().add("file.txt", "content").create();
+    String html = buildHtml("/" + REPO_NAME + "/+/" + commit.name(), false);
+    assertThemeElementsPresent(html);
+  }
+
+  @Test
+  public void logDetailIncludesThemeScriptAndToggle() throws Exception {
+    repo.branch("master").commit().add("file.txt", "content").create();
+    String html = buildHtml("/" + REPO_NAME + "/+log/master", false);
+    assertThemeElementsPresent(html);
+  }
+
+  @Test
+  public void treeDetailIncludesThemeScriptAndToggle() throws Exception {
+    repo.branch("master").commit().add("dir/file.txt", "content").create();
+    String html = buildHtml("/" + REPO_NAME + "/+/master/dir/", false);
+    assertThemeElementsPresent(html);
+  }
+
+  @Test
+  public void blobDetailIncludesThemeScriptAndToggle() throws Exception {
+    repo.branch("master").commit().add("file.txt", "line 1\nline 2\n").create();
+    String html = buildHtml("/" + REPO_NAME + "/+/master/file.txt", false);
+    assertThemeElementsPresent(html);
+  }
+
+  @Test
+  public void diffDetailIncludesThemeScriptAndToggle() throws Exception {
+    RevCommit commit = repo.branch("master").commit().add("file.txt", "line 1\nline 2\n").create();
+    String html = buildHtml("/" + REPO_NAME + "/+diff/" + commit.name() + "^!/", false);
+    assertThemeElementsPresent(html);
+  }
+
+  @Test
+  public void blameDetailIncludesThemeScriptAndToggle() throws Exception {
+    repo.branch("master").commit().add("file.txt", "line 1\nline 2\n").create();
+    String html = buildHtml("/" + REPO_NAME + "/+blame/master/file.txt", false);
+    assertThemeElementsPresent(html);
+  }
+
+  @Test
+  public void refsDetailIncludesThemeScriptAndToggle() throws Exception {
+    repo.branch("master").commit().add("file.txt", "content").create();
+    String html = buildHtml("/" + REPO_NAME + "/+refs/", false);
+    assertThemeElementsPresent(html);
+  }
+
+  @Test
+  public void errorPageIncludesThemeScriptAndToggle() throws Exception {
+    FakeHttpServletResponse res = buildResponse("/" + REPO_NAME + "/+/nonexistent-ref", null, SC_NOT_FOUND);
+    String html = res.getActualBodyString();
+    assertThemeElementsPresent(html);
+  }
+
+  private static void assertThemeElementsPresent(String html) {
+    assertThat(html).contains("id=\"gitiles-theme-toggle\"");
+    assertThat(html).contains("Header-themeToggle");
+    assertThat(html).contains("gitiles-theme");
+    assertThat(html).contains("data-theme");
+    assertThat(html).contains("<button");
+    assertThat(html).contains("<script>");
+  }
+}
diff --git a/javatests/com/google/gitiles/doc/DocServletTest.java b/javatests/com/google/gitiles/doc/DocServletTest.java
index 110f481..27e827f 100644
--- a/javatests/com/google/gitiles/doc/DocServletTest.java
+++ b/javatests/com/google/gitiles/doc/DocServletTest.java
@@ -121,7 +121,7 @@
     assertThat(html).contains("Non-HTML is fine.");
 
     assertThat(html).doesNotContain("window.alert");
-    assertThat(html).doesNotContain("<script>");
+    assertThat(html).doesNotContain("<script>window.alert();</script>");
   }
 
   @Test
diff --git a/resources/com/google/gitiles/static/base.css b/resources/com/google/gitiles/static/base.css
index 3ec4b5d..095cc99 100644
--- a/resources/com/google/gitiles/static/base.css
+++ b/resources/com/google/gitiles/static/base.css
@@ -18,6 +18,143 @@
 
 @import "//fonts.googleapis.com/css?family=Open+Sans:300,400,700&subset=latin,cyrillic-ext,greek-ext,cyrillic,greek,vietnamese,latin-ext";
 @import "//fonts.googleapis.com/css?family=Source+Code+Pro";
+
+:root,
+html[data-theme="light"] {
+  --bg-color: #fff;
+  --text-color: #000;
+  --header-bg: #eee;
+  --header-with-navbar-bg: #fff;
+  --header-anchor-color: #666;
+  --header-nav-bg: #eee;
+  --footer-color: #666;
+  --link-color: #00e;
+  --hover-bg: #eee;
+  --header-hover-bg: #fff;
+  --border-color: #ddd;
+  --border-subtle-color: #ccc;
+  --code-bg: #f1f2f3;
+  --line-num-color: #666;
+  --line-num-border: #f1f2f3;
+  --line-num-attr-color: #aaa;
+  --target-line-bg: #cfd8dc;
+  --clone-bg: #eee;
+  --clone-border: #ccc;
+  --metadata-msg-bg: #fafafa;
+  --metadata-msg-border: #ccc;
+  --blame-bg1: #fff;
+  --blame-bg2: #f1f2f3;
+  --diff-file-index: #444;
+  --diff-hunk: #00c;
+  --diff-delete: #c00;
+  --diff-insert: #080;
+  --diff-change: #960;
+  --diff-action-add: #060;
+  --diff-action-delete: #600;
+  --diff-action-rename: #006;
+  --branch-label-color: #dd4b39;
+  --tag-label-color: #093;
+  --muted-text-color: #666;
+  --toggle-btn-color: #666;
+  --toggle-btn-border: #ccc;
+  --toggle-btn-hover-color: #000;
+  --toggle-btn-hover-bg: rgba(0, 0, 0, 0.05);
+}
+
+:root {
+  color-scheme: light dark;
+}
+
+html[data-theme="light"] {
+  color-scheme: light;
+}
+
+@media (prefers-color-scheme: dark) {
+  :root {
+    color-scheme: dark;
+    --bg-color: #202124;
+    --text-color: #e8eaed;
+    --header-bg: #292a2d;
+    --header-with-navbar-bg: #202124;
+    --header-anchor-color: #9aa0a6;
+    --header-nav-bg: #292a2d;
+    --footer-color: #9aa0a6;
+    --link-color: #8ab4f8;
+    --hover-bg: #35363a;
+    --header-hover-bg: #202124;
+    --border-color: #3c4043;
+    --border-subtle-color: #5f6368;
+    --code-bg: #292a2d;
+    --line-num-color: #9aa0a6;
+    --line-num-border: #3c4043;
+    --line-num-attr-color: #80868b;
+    --target-line-bg: #174ea6;
+    --clone-bg: #292a2d;
+    --clone-border: #5f6368;
+    --metadata-msg-bg: #292a2d;
+    --metadata-msg-border: #3c4043;
+    --blame-bg1: #202124;
+    --blame-bg2: #292a2d;
+    --diff-file-index: #bdc1c6;
+    --diff-hunk: #8ab4f8;
+    --diff-delete: #f28b82;
+    --diff-insert: #81c995;
+    --diff-change: #fdd663;
+    --diff-action-add: #81c995;
+    --diff-action-delete: #f28b82;
+    --diff-action-rename: #8ab4f8;
+    --branch-label-color: #f28b82;
+    --tag-label-color: #81c995;
+    --muted-text-color: #9aa0a6;
+    --toggle-btn-color: #9aa0a6;
+    --toggle-btn-border: #5f6368;
+    --toggle-btn-hover-color: #e8eaed;
+    --toggle-btn-hover-bg: rgba(255, 255, 255, 0.1);
+  }
+}
+
+html[data-theme="dark"] {
+  color-scheme: dark;
+  --bg-color: #202124;
+  --text-color: #e8eaed;
+  --header-bg: #292a2d;
+  --header-with-navbar-bg: #202124;
+  --header-anchor-color: #9aa0a6;
+  --header-nav-bg: #292a2d;
+  --footer-color: #9aa0a6;
+  --link-color: #8ab4f8;
+  --hover-bg: #35363a;
+  --header-hover-bg: #202124;
+  --border-color: #3c4043;
+  --border-subtle-color: #5f6368;
+  --code-bg: #292a2d;
+  --line-num-color: #9aa0a6;
+  --line-num-border: #3c4043;
+  --line-num-attr-color: #80868b;
+  --target-line-bg: #174ea6;
+  --clone-bg: #292a2d;
+  --clone-border: #5f6368;
+  --metadata-msg-bg: #292a2d;
+  --metadata-msg-border: #3c4043;
+  --blame-bg1: #202124;
+  --blame-bg2: #292a2d;
+  --diff-file-index: #bdc1c6;
+  --diff-hunk: #8ab4f8;
+  --diff-delete: #f28b82;
+  --diff-insert: #81c995;
+  --diff-change: #fdd663;
+  --diff-action-add: #81c995;
+  --diff-action-delete: #f28b82;
+  --diff-action-rename: #8ab4f8;
+  --branch-label-color: #f28b82;
+  --tag-label-color: #81c995;
+  --muted-text-color: #9aa0a6;
+  --toggle-btn-color: #9aa0a6;
+  --toggle-btn-border: #5f6368;
+  --toggle-btn-hover-color: #e8eaed;
+  --toggle-btn-hover-bg: rgba(255, 255, 255, 0.1);
+}
+
 *,
 *::after,
 *::before {
@@ -45,12 +182,15 @@
 ul, ol {
   list-style: none;
 }
+a {
+  color: var(--link-color);
+}
 
 /* Utility classes */
 
 .u-sha1 {
-  background-color: #f1f2f3;
-  color: #000;
+  background-color: var(--code-bg);
+  color: var(--text-color);
   font-size: 13px;
 }
 .u-pre {
@@ -58,8 +198,8 @@
   white-space: pre;
 }
 .u-lineNum {
-  border-right: 1px solid #f1f2f3;
-  color: #666;
+  border-right: 1px solid var(--line-num-border);
+  color: var(--line-num-color);
   display: inline-block;
   min-width: 3em;
   text-align: right;
@@ -77,8 +217,8 @@
 /* Common.soy */
 
 .Site {
-  background: #fff;
-  color: #000;
+  background: var(--bg-color);
+  color: var(--text-color);
   display: -ms-flexbox;
   display: flex;
   font: 14px/1.54 'Open Sans', sans-serif;
@@ -88,12 +228,12 @@
 }
 .Site-header,
 .Site-footer {
-  background: #eee;
+  background: var(--header-bg);
   -ms-flex: none;
   flex: none;
 }
 .Site-header--withNavbar {
-  background: #fff;
+  background: var(--header-with-navbar-bg);
 }
 .Site-content {
   -ms-flex: 1 0 auto;
@@ -128,7 +268,7 @@
   flex: 1;
 }
 .Header-anchor {
-  color: #666;
+  color: var(--header-anchor-color);
   font-size: 32px;
   font-weight: 300;
   text-decoration: none;
@@ -139,7 +279,7 @@
   vertical-align: middle;
 }
 .Header-nav {
-  background: #eee;
+  background: var(--header-nav-bg);
   padding: 15px 0;
 }
 .Header-menu {
@@ -147,15 +287,35 @@
   display: flex;
   -ms-flex-pack: end;
   justify-content: flex-end;
+  align-items: center;
 }
 .Header-menuItem {
-  color: #00e;
+  color: var(--link-color);
   display: inline-block;
   margin-left: 15px;
 }
 .Header-menuItem--noAction {
   color: inherit;
 }
+.Header-themeToggle {
+  background: none;
+  border: 1px solid var(--toggle-btn-border);
+  border-radius: 4px;
+  color: var(--toggle-btn-color);
+  cursor: pointer;
+  font: inherit;
+  font-size: 13px;
+  line-height: 1;
+  padding: 4px 8px;
+  display: inline-flex;
+  align-items: center;
+  gap: 4px;
+}
+.Header-themeToggle:hover {
+  background: var(--toggle-btn-hover-bg);
+  color: var(--toggle-btn-hover-color);
+  border-color: var(--border-subtle-color);
+}
 .Breadcrumbs {
   font-size: 18px;
   margin-bottom: 20px;
@@ -168,14 +328,14 @@
   user-select: none;
 }
 .Breadcrumbs-crumb {
-  color: #00e;
+  color: var(--link-color);
 }
 .Breadcrumbs-crumb:last-child {
-  color: #000;
+  color: var(--text-color);
   font-weight: bold;
 }
 .Footer {
-  color: #666;
+  color: var(--footer-color);
 }
 .Footer-poweredBy {
   -ms-flex: 1;
@@ -212,14 +372,14 @@
   text-decoration: none;
 }
 .RepoList-item:hover {
-  background: #eee;
+  background: var(--hover-bg);
 }
 .RepoList-item--header {
   font-weight: bold;
   margin: 0;
 }
 .RepoList-item--header:hover {
-  background: #fff;
+  background: var(--header-hover-bg);
 }
 .RepoList-itemName,
 .RepoList-itemDescription {
@@ -234,7 +394,7 @@
   text-decoration: none;
 }
 .RepoList-itemDescription {
-  color: #000;
+  color: var(--text-color);
   -ms-flex: 1;
   flex: 1;
   overflow: hidden;
@@ -245,21 +405,25 @@
 
 .RepoDescription {
   margin: 10px 0;
+  color: var(--text-color);
 }
 .RepoMirroredFrom {
   margin: 10px 0;
-  color: #666;
+  color: var(--muted-text-color);
 }
 .CloneRepo {
-  background: #eee;
+  background: var(--clone-bg);
   margin-bottom: 20px;
   padding: 10px;
 }
 .CloneRepo-title {
   margin-bottom: 2px;
+  color: var(--text-color);
 }
 .CloneRepo-command {
-  border: 1px solid #ccc;
+  background: var(--bg-color);
+  color: var(--text-color);
+  border: 1px solid var(--clone-border);
   border-radius: 4px;
   display: block;
   font-size: inherit;
@@ -284,14 +448,17 @@
   width: 80%;
 }
 .RepoIndexDoc {
-  border-top: 1px solid #ddd;
+  border-top: 1px solid var(--border-color);
   margin-top: 20px;
   padding-top: 5px;
 }
-.EmptyRepo-header {}
+.EmptyRepo-header {
+  color: var(--text-color);
+}
 .EmptyRepo-description {
   font-size: 14px;
   font-style: italic;
+  color: var(--muted-text-color);
 }
 @media (max-width: 550px) {
   .RepoShortlog {
@@ -300,7 +467,7 @@
   }
   .RepoShortlog-refs {
     width: auto;
-    border-bottom: 1px solid #ddd;
+    border-bottom: 1px solid var(--border-color);
     margin-bottom: 20px;
     padding-bottom: 20px;
   }
@@ -320,6 +487,7 @@
 }
 .RefList-title {
   margin: 0;
+  color: var(--text-color);
 }
 .RefList-items {
   word-wrap: break-word;
@@ -345,7 +513,7 @@
   padding: 2px 0;
 }
 .CommitLog-item--oneline:hover {
-  background: #eee;
+  background: var(--hover-bg);
 }
 .CommitLog-item--full {
   margin-bottom: 20px;
@@ -353,8 +521,11 @@
 .CommitLog-item--empty {
   padding: 10px 0;
   text-align: center;
+  color: var(--muted-text-color);
 }
 .CommitLog-sha1 {
+  background-color: var(--code-bg);
+  color: var(--text-color);
   border-radius: 3px;
   display: inline-block;
   margin-right: 3px;
@@ -362,18 +533,19 @@
   text-align: center;
 }
 .CommitLog-time {
-  color: #666;
+  color: var(--muted-text-color);
 }
 .CommitLog-branchLabel {
-  color: #dd4b39;
+  color: var(--branch-label-color);
 }
 .CommitLog-tagLabel {
-  color: #093;
+  color: var(--tag-label-color);
 }
 .CommitLog-rename {
   font-size: 0.9em;
   display: block;
   padding-left: 5px;
+  color: var(--muted-text-color);
 }
 
 /* ObjectDetail.soy */
@@ -395,6 +567,7 @@
   grid-column: 1;
   font-weight: bold;
   text-align: right;
+  color: var(--text-color);
 }
 .Metadata-description {
   grid-column: 2 / -1;
@@ -405,9 +578,9 @@
   grid-column: auto;
 }
 .MetadataMessage {
-  background-color: #fafafa;
-  border: 1px solid #ccc;
-  color: #000;
+  background-color: var(--metadata-msg-bg);
+  border: 1px solid var(--metadata-msg-border);
+  color: var(--text-color);
   margin: 0;
   padding: 12px;
   white-space: pre-wrap;
@@ -419,20 +592,22 @@
   margin-left: .5em;
 }
 .DiffTree-action--add {
-  color: #060;
+  color: var(--diff-action-add);
 }
 .DiffTree-action--delete {
-  color: #600;
+  color: var(--diff-action-delete);
 }
 .DiffTree-action--rename,
 .DiffTree-action--copy {
-  color: #006;
+  color: var(--diff-action-rename);
 }
 .DiffSummary {}
 .TreeDetail-sha1,
 .BlobSha1 {
   margin: 10px 0;
   padding: 5px 10px;
+  background-color: var(--code-bg);
+  color: var(--text-color);
 }
 .FileList {
   margin-left: 25px;
@@ -442,7 +617,7 @@
   position: relative;
 }
 .FileList-item:hover {
-  background: #eee;
+  background: var(--hover-bg);
 }
 .FileList-item::before {
   left: -22px;
@@ -479,6 +654,7 @@
   border-collapse: collapse;
   border-spacing: 0;
   margin: 10px 0;
+  color: var(--text-color);
 }
 .FileContents-line {
   border: none;
@@ -489,7 +665,7 @@
 }
 /* Used to prevent copying the line number. */
 .FileContents-lineNum::before {
-  color: #aaa;
+  color: var(--line-num-attr-color);
   content: attr(data-line-number);
   cursor: pointer;
 }
@@ -499,15 +675,15 @@
   padding-left: 10px;
 }
 .FileContents-lineContents:target {
-  background: #cfd8dc;
+  background: var(--target-line-bg);
 }
 .InlineReadme {
-  border-top: 1px solid #ddd;
+  border-top: 1px solid var(--border-color);
   margin: 10px 0;
   padding: 7px 0;
 }
 .InlineReadme-path {
-  color: #666;
+  color: var(--muted-text-color);
 }
 
 @media (max-width: 700px) {
@@ -525,12 +701,13 @@
   border-collapse: collapse;
   font-size: 8pt;
   margin: 0 auto;
+  color: var(--text-color);
 }
 .Blame-region--bg1 {
-  background: #fff;
+  background: var(--blame-bg1);
 }
 .Blame-region--bg2 {
-  background: #f1f2f3;
+  background: var(--blame-bg2);
 }
 .Blame-sha1,
 .Blame-author,
@@ -562,20 +739,24 @@
   margin: 10px 0;
 }
 .Diff-fileIndex {
-  color: #444;
+  color: var(--diff-file-index);
   font-weight: bold;
 }
 .Diff-unified {
-  border-bottom: 1px solid #ddd;
-  border-top: 1px solid #ddd;
+  border-bottom: 1px solid var(--border-color);
+  border-top: 1px solid var(--border-color);
   padding: 10px 0;
+  color: var(--text-color);
 }
 .Diff-hunk {
-  color: #00c;
+  color: var(--diff-hunk);
 }
 .Diff-delete {
-  color: #c00;
+  color: var(--diff-delete);
 }
 .Diff-insert {
-  color: #080;
+  color: var(--diff-insert);
+}
+.Diff-change {
+  color: var(--diff-change);
 }
diff --git a/resources/com/google/gitiles/static/doc.css b/resources/com/google/gitiles/static/doc.css
index 8a92fce..3164268 100644
--- a/resources/com/google/gitiles/static/doc.css
+++ b/resources/com/google/gitiles/static/doc.css
@@ -16,6 +16,99 @@
 
 /* Markdown rendered in /+doc/ or tree view page . */
 
+:root,
+html[data-theme="light"] {
+  --doc-hr: #aaa;
+  --doc-link: #245dc1;
+  --doc-link-visited: #7759ae;
+  --doc-link-heading: #444;
+  --doc-heading-hash: #aaa;
+  --doc-blockquote-bg: #fffde7;
+  --doc-blockquote-border: #fab700;
+  --doc-blockquote-text: #000;
+  --doc-pre-bg: #fafafa;
+  --doc-pre-border: #ccc;
+  --doc-pre-text: #000;
+  --doc-code-bg: #f5f5f5;
+  --doc-code-text: #000;
+  --doc-table-border: #eee;
+  --doc-th-bg: #f5f5f5;
+  --doc-th-text: #000;
+  --doc-toc-bg: #f9f9f9;
+  --doc-toc-border: #f2f2f2;
+  --doc-note-bg: #fffbe4;
+  --doc-note-border: #f8f6e6;
+  --doc-note-text: #202124;
+  --doc-promo-bg: #f6f9ff;
+  --doc-promo-border: #eff2f9;
+  --doc-promo-text: #202124;
+  --doc-aside-bg: #f9f9f9;
+  --doc-aside-border: #f2f2f2;
+  --doc-aside-text: #202124;
+}
+
+@media (prefers-color-scheme: dark) {
+  :root {
+    --doc-hr: #5f6368;
+    --doc-link: #8ab4f8;
+    --doc-link-visited: #c58af9;
+    --doc-link-heading: #9aa0a6;
+    --doc-heading-hash: #5f6368;
+    --doc-blockquote-bg: #3c381e;
+    --doc-blockquote-border: #fdd663;
+    --doc-blockquote-text: #e8eaed;
+    --doc-pre-bg: #292a2d;
+    --doc-pre-border: #3c4043;
+    --doc-pre-text: #e8eaed;
+    --doc-code-bg: #2d3035;
+    --doc-code-text: #e8eaed;
+    --doc-table-border: #3c4043;
+    --doc-th-bg: #292a2d;
+    --doc-th-text: #e8eaed;
+    --doc-toc-bg: #292a2d;
+    --doc-toc-border: #3c4043;
+    --doc-note-bg: #332d00;
+    --doc-note-border: #4d4400;
+    --doc-note-text: #e8eaed;
+    --doc-promo-bg: #041e49;
+    --doc-promo-border: #0b386f;
+    --doc-promo-text: #e8eaed;
+    --doc-aside-bg: #292a2d;
+    --doc-aside-border: #3c4043;
+    --doc-aside-text: #e8eaed;
+  }
+}
+
+html[data-theme="dark"] {
+  --doc-hr: #5f6368;
+  --doc-link: #8ab4f8;
+  --doc-link-visited: #c58af9;
+  --doc-link-heading: #9aa0a6;
+  --doc-heading-hash: #5f6368;
+  --doc-blockquote-bg: #3c381e;
+  --doc-blockquote-border: #fdd663;
+  --doc-blockquote-text: #e8eaed;
+  --doc-pre-bg: #292a2d;
+  --doc-pre-border: #3c4043;
+  --doc-pre-text: #e8eaed;
+  --doc-code-bg: #2d3035;
+  --doc-code-text: #e8eaed;
+  --doc-table-border: #3c4043;
+  --doc-th-bg: #292a2d;
+  --doc-th-text: #e8eaed;
+  --doc-toc-bg: #292a2d;
+  --doc-toc-border: #3c4043;
+  --doc-note-bg: #332d00;
+  --doc-note-border: #4d4400;
+  --doc-note-text: #e8eaed;
+  --doc-promo-bg: #041e49;
+  --doc-promo-border: #0b386f;
+  --doc-promo-text: #e8eaed;
+  --doc-aside-bg: #292a2d;
+  --doc-aside-border: #3c4043;
+  --doc-aside-text: #e8eaed;
+}
+
 .Site-Content--markdown {
   padding-top: 0;
 }
@@ -60,7 +153,7 @@
 }
 .doc hr {
   border: none;
-  border-top: 1px solid #aaa;
+  border-top: 1px solid var(--doc-hr);
   display: block;
   margin-top: 25px;
 }
@@ -68,10 +161,10 @@
   text-decoration: none;
 }
 .doc a:link {
-  color: #245dc1;
+  color: var(--doc-link);
 }
 .doc a:visited {
-  color: #7759ae;
+  color: var(--doc-link-visited);
 }
 .doc a:hover {
   text-decoration: underline;
@@ -86,7 +179,7 @@
 }
 .doc a.h:link,
 .doc a.h:visited {
-  color: #444;
+  color: var(--doc-link-heading);
 }
 .doc a.h:focus {
   outline: none;
@@ -106,7 +199,7 @@
 .doc h6:hover a.h span:before {
   content: '#';
   font-weight: normal;
-  color: #AAA;
+  color: var(--doc-heading-hash);
 }
 .doc h1:hover a:hover.h span:before,
 .doc h2:hover a:hover.h span:before,
@@ -122,7 +215,7 @@
 .doc h4:hover a:visited.h span:before,
 .doc h5:hover a:visited.h span:before,
 .doc h6:hover a:visited.h span:before {
-  color: #AAA;
+  color: var(--doc-heading-hash);
 }
 .doc ul, .doc ol {
   margin: 10px 10px 10px 30px;
@@ -157,20 +250,23 @@
   margin: 10px 0;
 }
 .doc blockquote {
-  background-color: #fffde7;
-  border-left: 2px solid #fab700;
+  background-color: var(--doc-blockquote-bg);
+  border-left: 2px solid var(--doc-blockquote-border);
+  color: var(--doc-blockquote-text);
   padding: 2px 10px 2px 10px;
 }
 .doc pre {
   padding: 12px;
   font-size: 10pt;
-  background-color: #fafafa;
-  border: 1px solid #ccc;
+  background-color: var(--doc-pre-bg);
+  border: 1px solid var(--doc-pre-border);
+  color: var(--doc-pre-text);
   overflow-x: auto;
 }
 .doc code {
   padding: 2px 4px;
-  background-color: #F5F5F5;
+  background-color: var(--doc-code-bg);
+  color: var(--doc-code-text);
   border: transparent;
   border-radius: 4px;
 }
@@ -189,19 +285,20 @@
 }
 .doc th,
 .doc td {
-  border: 1px solid #eee;
+  border: 1px solid var(--doc-table-border);
   padding: 4px 12px;
   vertical-align: top;
 }
 .doc th {
-  background-color: #f5f5f5;
+  background-color: var(--doc-th-bg);
+  color: var(--doc-th-text);
 }
 .toc {
   margin-top: 30px;
 }
 .toc-aux {
-  background: #f9f9f9;
-  border: 1px solid #f2f2f2;
+  background: var(--doc-toc-bg);
+  border: 1px solid var(--doc-toc-border);
 }
 .toc h2 {
   margin: 0 0 5px 0;
@@ -229,16 +326,19 @@
   padding: 10px;
 }
 .note {
-  background: #fffbe4;
-  border-color: #f8f6e6;
+  background: var(--doc-note-bg);
+  border-color: var(--doc-note-border);
+  color: var(--doc-note-text);
 }
 .promo {
-  background: #f6f9ff;
-  border-color: #eff2f9;
+  background: var(--doc-promo-bg);
+  border-color: var(--doc-promo-border);
+  color: var(--doc-promo-text);
 }
 .aside {
-  background: #f9f9f9;
-  border-color: #f2f2f2;
+  background: var(--doc-aside-bg);
+  border-color: var(--doc-aside-border);
+  color: var(--doc-aside-text);
 }
 .note :first-child,
 .promo :first-child,
diff --git a/resources/com/google/gitiles/static/prettify/prettify.css b/resources/com/google/gitiles/static/prettify/prettify.css
index d44b3a2..5cd8844 100644
--- a/resources/com/google/gitiles/static/prettify/prettify.css
+++ b/resources/com/google/gitiles/static/prettify/prettify.css
@@ -1 +1,104 @@
-.pln{color:#000}@media screen{.str{color:#080}.kwd{color:#008}.com{color:#800}.typ{color:#606}.lit{color:#066}.pun,.opn,.clo{color:#660}.tag{color:#008}.atn{color:#606}.atv{color:#080}.dec,.var{color:#606}.fun{color:red}}@media print,projection{.str{color:#060}.kwd{color:#006;font-weight:bold}.com{color:#600;font-style:italic}.typ{color:#404;font-weight:bold}.lit{color:#044}.pun,.opn,.clo{color:#440}.tag{color:#006;font-weight:bold}.atn{color:#404}.atv{color:#060}}pre.prettyprint{padding:2px;border:1px solid #888}ol.linenums{margin-top:0;margin-bottom:0}li.L0,li.L1,li.L2,li.L3,li.L5,li.L6,li.L7,li.L8{list-style-type:none}li.L1,li.L3,li.L5,li.L7,li.L9{background:#eee}
\ No newline at end of file
+:root {
+  --prettify-pln: #000;
+  --prettify-str: #080;
+  --prettify-kwd: #008;
+  --prettify-com: #800;
+  --prettify-typ: #606;
+  --prettify-lit: #066;
+  --prettify-pun: #660;
+  --prettify-tag: #008;
+  --prettify-atn: #606;
+  --prettify-atv: #080;
+  --prettify-dec: #606;
+  --prettify-var: #606;
+  --prettify-fun: red;
+  --prettify-border: #888;
+  --prettify-line-alt: #eee;
+}
+
+@media (prefers-color-scheme: dark) {
+  :root {
+    --prettify-pln: #e8eaed;
+    --prettify-str: #81c995;
+    --prettify-kwd: #8ab4f8;
+    --prettify-com: #9aa0a6;
+    --prettify-typ: #c58af9;
+    --prettify-lit: #78d9ec;
+    --prettify-pun: #fdd663;
+    --prettify-tag: #8ab4f8;
+    --prettify-atn: #c58af9;
+    --prettify-atv: #81c995;
+    --prettify-dec: #c58af9;
+    --prettify-var: #c58af9;
+    --prettify-fun: #ee675c;
+    --prettify-border: #3c4043;
+    --prettify-line-alt: #292a2d;
+  }
+}
+
+html[data-theme="dark"] {
+  --prettify-pln: #e8eaed;
+  --prettify-str: #81c995;
+  --prettify-kwd: #8ab4f8;
+  --prettify-com: #9aa0a6;
+  --prettify-typ: #c58af9;
+  --prettify-lit: #78d9ec;
+  --prettify-pun: #fdd663;
+  --prettify-tag: #8ab4f8;
+  --prettify-atn: #c58af9;
+  --prettify-atv: #81c995;
+  --prettify-dec: #c58af9;
+  --prettify-var: #c58af9;
+  --prettify-fun: #ee675c;
+  --prettify-border: #3c4043;
+  --prettify-line-alt: #292a2d;
+}
+
+html[data-theme="light"] {
+  --prettify-pln: #000;
+  --prettify-str: #080;
+  --prettify-kwd: #008;
+  --prettify-com: #800;
+  --prettify-typ: #606;
+  --prettify-lit: #066;
+  --prettify-pun: #660;
+  --prettify-tag: #008;
+  --prettify-atn: #606;
+  --prettify-atv: #080;
+  --prettify-dec: #606;
+  --prettify-var: #606;
+  --prettify-fun: red;
+  --prettify-border: #888;
+  --prettify-line-alt: #eee;
+}
+
+.pln { color: var(--prettify-pln); }
+@media screen {
+  .str { color: var(--prettify-str); }
+  .kwd { color: var(--prettify-kwd); }
+  .com { color: var(--prettify-com); }
+  .typ { color: var(--prettify-typ); }
+  .lit { color: var(--prettify-lit); }
+  .pun, .opn, .clo { color: var(--prettify-pun); }
+  .tag { color: var(--prettify-tag); }
+  .atn { color: var(--prettify-atn); }
+  .atv { color: var(--prettify-atv); }
+  .dec { color: var(--prettify-dec); }
+  .var { color: var(--prettify-var); }
+  .fun { color: var(--prettify-fun); }
+}
+@media print, projection {
+  .str { color: #060; }
+  .kwd { color: #006; font-weight: bold; }
+  .com { color: #600; font-style: italic; }
+  .typ { color: #404; font-weight: bold; }
+  .lit { color: #044; }
+  .pun, .opn, .clo { color: #440; }
+  .tag { color: #006; font-weight: bold; }
+  .atn { color: #404; }
+  .atv { color: #060; }
+}
+pre.prettyprint { padding: 2px; border: 1px solid var(--prettify-border); }
+ol.linenums { margin-top: 0; margin-bottom: 0; }
+li.L0, li.L1, li.L2, li.L3, li.L5, li.L6, li.L7, li.L8 { list-style-type: none; }
+li.L1, li.L3, li.L5, li.L7, li.L9 { background: var(--prettify-line-alt); }
diff --git a/resources/com/google/gitiles/static/theme-init.js b/resources/com/google/gitiles/static/theme-init.js
new file mode 100644
index 0000000..11cea0c
--- /dev/null
+++ b/resources/com/google/gitiles/static/theme-init.js
@@ -0,0 +1,28 @@
+// Copyright (C) 2026 Google Inc. All Rights Reserved.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+(function() {
+  try {
+    let t = localStorage.getItem('gitiles-theme');
+    if (!t) {
+      const m = document.cookie.match(/(?:^|; )gitiles-theme=([^;]*)/);
+      if (m) {
+        t = decodeURIComponent(m[1]);
+      }
+    }
+    if (t === 'dark' || t === 'light') {
+      document.documentElement.setAttribute('data-theme', t);
+    }
+  } catch (e) {}
+})();
diff --git a/resources/com/google/gitiles/static/theme-toggle.js b/resources/com/google/gitiles/static/theme-toggle.js
new file mode 100644
index 0000000..57c0fb6
--- /dev/null
+++ b/resources/com/google/gitiles/static/theme-toggle.js
@@ -0,0 +1,97 @@
+// Copyright (C) 2026 Google Inc. All Rights Reserved.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+(function() {
+  function getTheme() {
+    try {
+      const t = localStorage.getItem('gitiles-theme');
+      if (t) {
+        return t;
+      }
+    } catch (e) {}
+    try {
+      const m = document.cookie.match(/(?:^|; )gitiles-theme=([^;]*)/);
+      if (m) {
+        return decodeURIComponent(m[1]);
+      }
+    } catch (e) {}
+    return 'auto';
+  }
+
+  function applyTheme(theme) {
+    if (theme === 'dark' || theme === 'light') {
+      document.documentElement.setAttribute('data-theme', theme);
+    } else {
+      document.documentElement.removeAttribute('data-theme');
+    }
+  }
+
+  function updateButton(btn, theme) {
+    const icon = btn.querySelector('.Header-themeIcon');
+    const label = btn.querySelector('.Header-themeLabel');
+    if (theme === 'dark') {
+      if (icon) {
+        icon.textContent = '\u263E';
+      }
+      if (label) {
+        label.textContent = 'Dark';
+      }
+      btn.title = 'Theme: Dark (click to switch to Light)';
+    } else if (theme === 'light') {
+      if (icon) {
+        icon.textContent = '\u2600';
+      }
+      if (label) {
+        label.textContent = 'Light';
+      }
+      btn.title = 'Theme: Light (click to switch to Auto)';
+    } else {
+      if (icon) {
+        icon.textContent = '\u25D1';
+      }
+      if (label) {
+        label.textContent = 'Auto';
+      }
+      btn.title = 'Theme: Auto (click to switch to Dark)';
+    }
+    btn.setAttribute('aria-label', btn.title);
+  }
+
+  function initToggle() {
+    const btn = document.getElementById('gitiles-theme-toggle');
+    if (!btn) {
+      return;
+    }
+    const current = getTheme();
+    updateButton(btn, current);
+    btn.onclick = function() {
+      const cur = getTheme();
+      const next = (cur === 'auto') ? 'dark' : ((cur === 'dark') ? 'light' : 'auto');
+      applyTheme(next);
+      try {
+        localStorage.setItem('gitiles-theme', next);
+      } catch (e) {}
+      try {
+        document.cookie = 'gitiles-theme=' + encodeURIComponent(next) + ';path=/;max-age=31536000;SameSite=Lax';
+      } catch (e) {}
+      updateButton(btn, next);
+    };
+  }
+
+  if (document.readyState === 'loading') {
+    document.addEventListener('DOMContentLoaded', initToggle);
+  } else {
+    initToggle();
+  }
+})();
diff --git a/resources/com/google/gitiles/templates/Common.soy b/resources/com/google/gitiles/templates/Common.soy
index b5b1d29..371a10d 100644
--- a/resources/com/google/gitiles/templates/Common.soy
+++ b/resources/com/google/gitiles/templates/Common.soy
@@ -50,15 +50,15 @@
     {/for}
   {/if}
   {call customHeadTagPart variant="'' + ($customVariant ?? '')" /}
-
+  {call themeScript /}
 </head>
 <body class="Site">
   <header class="Site-header">
     <div class="Header">
       {call customHeader variant="'' + ($customVariant ?? '')" /}
 
+      <div class="Header-menu">
       {if $menuEntries && length($menuEntries)}
-        <div class="Header-menu">
         {for $entry in $menuEntries}
           {sp}
           {if $entry.url}
@@ -68,8 +68,9 @@
           {/if}
         {/for}
         {sp}
-        </div>
       {/if}
+      {call themeToggle /}
+      </div>
     </div>
   </header>
 
@@ -90,6 +91,26 @@
 {/template}
 
 /**
+ * Inline script to initialize theme preference without FOUC.
+ */
+{template themeScript}
+  {@inject THEME_INIT_SCRIPT: ?}
+<script>{$THEME_INIT_SCRIPT}</script>
+{/template}
+
+/**
+ * Theme toggle button and click handler.
+ */
+{template themeToggle}
+  {@inject THEME_TOGGLE_SCRIPT: ?}
+<button class="Header-menuItem Header-themeToggle" id="gitiles-theme-toggle" type="button" title="Toggle theme (Auto / Dark / Light)" aria-label="Toggle theme">
+  <span class="Header-themeIcon" aria-hidden="true">&#9681;</span>
+  <span class="Header-themeLabel">Theme</span>
+</button>
+<script>{$THEME_TOGGLE_SCRIPT}</script>
+{/template}
+
+/**
  * Default (empty) custom head tag part
  *
  * This can be used to include per-project CSS/JS by
diff --git a/resources/com/google/gitiles/templates/Doc.soy b/resources/com/google/gitiles/templates/Doc.soy
index 861acb7..36bf1d5 100644
--- a/resources/com/google/gitiles/templates/Doc.soy
+++ b/resources/com/google/gitiles/templates/Doc.soy
@@ -63,6 +63,7 @@
   <link rel="stylesheet" type="text/css" href="{$staticUrls.DOC_CSS_URL}" />
   <link rel="stylesheet" type="text/css" href="{$staticUrls.PRETTIFY_CSS_URL}" />
   {call common.customHeadTagPart variant="'' + ($customVariant ?? '')" /}
+  {call common.themeScript /}
 </head>
 <body class="Site">
   <header class="Site-header {if $navbarHtml}Site-header--withNavbar{/if}">
@@ -73,6 +74,9 @@
         {if $siteTitle}<span class="Header-anchorTitle">{$siteTitle}</span>{/if}
         {if $homeUrl}</a>{/if}
       </div>
+      <div class="Header-menu">
+        {call common.themeToggle /}
+      </div>
     </div>
     {if $navbarHtml}
       <nav class="Header-nav" role="navigation">