diff --git a/hypha/static_src/javascript/behaviours/theme-toggle.js b/hypha/static_src/javascript/behaviours/theme-toggle.js index adb05d1c8a..988c8610d1 100644 --- a/hypha/static_src/javascript/behaviours/theme-toggle.js +++ b/hypha/static_src/javascript/behaviours/theme-toggle.js @@ -1,16 +1,58 @@ let prefersDark = window.matchMedia("(prefers-color-scheme: dark)").matches; +/** + * Read the stored theme preference. + * + * Storage can be unavailable (blocked cookies, some private browsing modes) + * and then throws. This script runs blocking in , so an uncaught error + * would leave the page with no theme applied at all. + * + * @returns {string|null} "light", "dark", "auto", or null if nothing is stored. + */ +function getStoredTheme() { + try { + return localStorage.getItem("theme"); + } catch (_e) { + return null; + } +} + +/** + * Persist the theme preference, ignoring unavailable storage. + * + * @param {string} mode - "light", "dark" or "auto". + * @returns {boolean} Whether the preference could be stored. + */ +function storeTheme(mode) { + try { + localStorage.setItem("theme", mode); + return true; + } catch (_e) { + return false; + } +} + function setTheme(mode) { if (mode !== "light" && mode !== "dark" && mode !== "auto") { console.error(`Got invalid theme mode: ${mode}. Resetting to auto.`); mode = "auto"; } - document.documentElement.dataset.theme = mode; - localStorage.setItem("theme", mode); + + // daisyUI applies the dark theme through `:root:not([data-theme])` inside a + // prefers-color-scheme media query, so auto mode has to leave the attribute + // off entirely. Setting data-theme="auto" matches no theme and silently + // falls back to light. + if (mode === "auto") { + delete document.documentElement.dataset.theme; + } else { + document.documentElement.dataset.theme = mode; + } + + storeTheme(mode); } function cycleTheme() { - const currentTheme = localStorage.getItem("theme") || "auto"; + const currentTheme = getStoredTheme() || "auto"; if (prefersDark) { // Auto (dark) -> Light -> Dark @@ -35,7 +77,7 @@ function cycleTheme() { function initTheme() { // set theme defined in localStorage if there is one, or fallback to auto mode - const currentTheme = localStorage.getItem("theme"); + const currentTheme = getStoredTheme(); currentTheme ? setTheme(currentTheme) : setTheme("auto"); } diff --git a/hypha/static_src/javascript/tinymce-dark-mode.js b/hypha/static_src/javascript/tinymce-dark-mode.js index 2a7bf9a5a1..713c9c35ea 100644 --- a/hypha/static_src/javascript/tinymce-dark-mode.js +++ b/hypha/static_src/javascript/tinymce-dark-mode.js @@ -58,9 +58,11 @@ }); }); - // The theme toggle sets data-theme on . It also re-sets the attribute - // when the OS preference changes while in "auto" mode, so this covers both. - new MutationObserver(function () { + /** + * Point every editor, current and future, at the stylesheet for the theme + * that is now in effect. + */ + function syncContentCss() { const name = contentCss(); // Editors created from here on, e.g. by HTMX swapping in a new form. @@ -69,8 +71,19 @@ for (const editor of tinymce.get() ?? []) { swapContentCss(editor, name); } - }).observe(document.documentElement, { + } + + // The theme toggle sets data-theme on for the light and dark modes, + // and removes it again for auto mode. Attribute removal is an attribute + // mutation too, so both directions are covered. + new MutationObserver(syncContentCss).observe(document.documentElement, { attributes: true, attributeFilter: ["data-theme"], }); + + // In auto mode there is no data-theme attribute, so an OS preference change + // repaints the page without mutating anything the observer above watches. + window + .matchMedia("(prefers-color-scheme: dark)") + .addEventListener("change", syncContentCss); })(); diff --git a/hypha/static_src/tailwind/components/theme-toggle.css b/hypha/static_src/tailwind/components/theme-toggle.css index fa3d2e3f8f..5a1e69fc98 100644 --- a/hypha/static_src/tailwind/components/theme-toggle.css +++ b/hypha/static_src/tailwind/components/theme-toggle.css @@ -2,7 +2,7 @@ display: none; } -html[data-theme="auto"] .theme-toggle svg.theme-icon-when-auto { +html:not([data-theme]) .theme-toggle svg.theme-icon-when-auto { display: block; } @@ -18,7 +18,7 @@ html[data-theme="light"] .theme-toggle svg.theme-icon-when-light { display: none; } -html[data-theme="auto"] .theme-toggle .theme-label-when-auto { +html:not([data-theme]) .theme-toggle .theme-label-when-auto { display: block; } diff --git a/hypha/static_src/tailwind/main.css b/hypha/static_src/tailwind/main.css index 95e077fc7e..9a68e4d768 100644 --- a/hypha/static_src/tailwind/main.css +++ b/hypha/static_src/tailwind/main.css @@ -22,6 +22,21 @@ } @import "./base/themes.css"; +/* Tailwind's stock `dark:` variant keys off prefers-color-scheme, which ignores + the theme toggle. Follow the daisyUI themes instead: an explicit + data-theme="dark", or auto mode (no data-theme at all) on a dark OS. */ +@custom-variant dark { + &:where([data-theme="dark"], [data-theme="dark"] *) { + @slot; + } + + @media (prefers-color-scheme: dark) { + &:where(html:not([data-theme]), html:not([data-theme]) *) { + @slot; + } + } +} + @theme { --default-border-width: var(--border); diff --git a/hypha/templates/base-apply.html b/hypha/templates/base-apply.html index b53581de0e..3e74c82ba3 100644 --- a/hypha/templates/base-apply.html +++ b/hypha/templates/base-apply.html @@ -23,16 +23,12 @@ {% block user_menu %}