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">◑</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">