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)