diff --git a/io/src/main/resources/laika/helium/css/nav.css b/io/src/main/resources/laika/helium/css/nav.css
index 91adad5ab..13c0c3558 100644
--- a/io/src/main/resources/laika/helium/css/nav.css
+++ b/io/src/main/resources/laika/helium/css/nav.css
@@ -25,6 +25,29 @@ header a:hover, nav .row a:hover {
color: var(--component-hover)
}
+header .row button.theme-toggle {
+ margin: 0 0 0 20px;
+ padding: 0;
+ border: none;
+ background: none;
+ color: var(--component-color);
+ cursor: pointer;
+ font: inherit;
+ font-size: 0.9rem;
+ opacity: 0.8;
+}
+
+header .row button.theme-toggle:hover {
+ color: var(--component-hover);
+ opacity: 1;
+}
+
+header .row button.theme-toggle:focus-visible {
+ outline: 2px solid var(--component-border);
+ outline-offset: 3px;
+ border-radius: 4px;
+}
+
header .image-link {
height: var(--top-bar-height);
display: flex;
diff --git a/io/src/main/resources/laika/helium/js/theme.js b/io/src/main/resources/laika/helium/js/theme.js
index efb09e944..2df6dc436 100644
--- a/io/src/main/resources/laika/helium/js/theme.js
+++ b/io/src/main/resources/laika/helium/js/theme.js
@@ -61,8 +61,54 @@ function initMenuToggles () {
});
}
+function initColorModeToggle () {
+ const btn = document.getElementById("theme-toggle");
+ if (!btn) return;
+
+ function currentMode () {
+ const attr = document.documentElement.getAttribute("data-color-mode");
+ return (attr === "light" || attr === "dark") ? attr : "auto";
+ }
+
+ function applyMode (mode) {
+ if (mode === "light" || mode === "dark") {
+ document.documentElement.setAttribute("data-color-mode", mode);
+ try { localStorage.setItem("laika-color-mode", mode); } catch (e) {}
+ } else {
+ document.documentElement.removeAttribute("data-color-mode");
+ try { localStorage.removeItem("laika-color-mode"); } catch (e) {}
+ }
+ updateUi(mode);
+ }
+
+ function updateUi (mode) {
+ if (mode === "light") {
+ btn.textContent = "Light";
+ btn.title = "Switch to dark mode";
+ btn.setAttribute("aria-label", "Switch to dark mode");
+ } else if (mode === "dark") {
+ btn.textContent = "Dark";
+ btn.title = "Switch to system mode";
+ btn.setAttribute("aria-label", "Switch to system mode");
+ } else {
+ btn.textContent = "Auto";
+ btn.title = "Switch to light mode";
+ btn.setAttribute("aria-label", "Switch to light mode");
+ }
+ }
+
+ updateUi(currentMode());
+
+ btn.addEventListener("click", () => {
+ const mode = currentMode();
+ const next = (mode === "auto") ? "light" : (mode === "light") ? "dark" : "auto";
+ applyMode(next);
+ });
+}
+
document.addEventListener('DOMContentLoaded', () => {
initNavToggle();
initMenuToggles();
initTabs();
+ initColorModeToggle();
});
diff --git a/io/src/main/resources/laika/helium/templates/includes/head.template.html b/io/src/main/resources/laika/helium/templates/includes/head.template.html
index 0d7aeb26f..d098bb7e2 100644
--- a/io/src/main/resources/laika/helium/templates/includes/head.template.html
+++ b/io/src/main/resources/laika/helium/templates/includes/head.template.html
@@ -19,6 +19,20 @@
@:for(helium.webFonts)
@:@
+
@:includeCSS
@:includeJS
@:heliumInitVersions
diff --git a/io/src/main/resources/laika/helium/templates/includes/topNav.template.html b/io/src/main/resources/laika/helium/templates/includes/topNav.template.html
index cc68f21d1..8e7848112 100644
--- a/io/src/main/resources/laika/helium/templates/includes/topNav.template.html
+++ b/io/src/main/resources/laika/helium/templates/includes/topNav.template.html
@@ -6,6 +6,11 @@
${helium.site.topNavigation.versionMenu}
+ @:for(helium.site.darkModeEnabled)
+
+ @:@
${?helium.site.topNavigation.home}
diff --git a/io/src/main/scala/laika/helium/internal/generate/CSSVarGenerator.scala b/io/src/main/scala/laika/helium/internal/generate/CSSVarGenerator.scala
index 4959c7b0f..199b3f083 100644
--- a/io/src/main/scala/laika/helium/internal/generate/CSSVarGenerator.scala
+++ b/io/src/main/scala/laika/helium/internal/generate/CSSVarGenerator.scala
@@ -39,6 +39,24 @@ private[helium] object CSSVarGenerator {
| src: url("$path");
|}""".stripMargin
+ private def renderManualOverride(
+ mode: String,
+ vars: Seq[(String, String)]
+ ): String = {
+ val rendered =
+ vars.map { case (name, value) =>
+ s"$name: $value;"
+ }.mkString("\n ")
+
+ s"""
+ :root[data-color-mode="$mode"] {
+ $rendered
+ color-scheme: $mode;
+ }
+
+ """
+ }
+
def generate(settings: SiteSettings): String = {
import settings.layout.*
val layoutStyles = Seq(
@@ -183,13 +201,20 @@ private[helium] object CSSVarGenerator {
val (colorScheme, darkModeStyles) = common.darkMode match {
case Some(darkModeColors) =>
- (
- Seq(("color-scheme", "light dark")),
+ val darkVars = toVars(colorSet(darkModeColors, darkMode = true))
+ val lightVars = toVars(colorSet(common.colors, darkMode = false))
+
+ val darkMedia =
renderStyles(
- toVars(colorSet(darkModeColors, darkMode = true)),
+ darkVars,
includeInverted,
darkMode = true
)
+ val manualLight = renderManualOverride("light", lightVars)
+ val manualDark = renderManualOverride("dark", darkVars)
+ (
+ Seq(("color-scheme", "light dark")),
+ darkMedia + manualLight + manualDark
)
case None => (Nil, "")
}
diff --git a/io/src/main/scala/laika/helium/internal/generate/ConfigGenerator.scala b/io/src/main/scala/laika/helium/internal/generate/ConfigGenerator.scala
index 2a769794e..e1c831fa5 100644
--- a/io/src/main/scala/laika/helium/internal/generate/ConfigGenerator.scala
+++ b/io/src/main/scala/laika/helium/internal/generate/ConfigGenerator.scala
@@ -206,6 +206,10 @@ private[laika] object ConfigGenerator {
"helium.site.includePDF",
helium.siteSettings.content.downloadPage.fold(false)(_.includePDF)
)
+ .withValue(
+ "helium.site.darkModeEnabled",
+ helium.siteSettings.darkMode.map(_ => "enabled")
+ )
.withValue("helium.site.fontFamilies", helium.siteSettings.themeFonts)
.withValue("helium.epub.fontFamilies", helium.epubSettings.themeFonts)
.withValue("helium.pdf.fontFamilies", helium.pdfSettings.themeFonts)