Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
67 changes: 53 additions & 14 deletions docs/setup/administrators/theming.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ A handful of overridden values is enough to make Hypha look like it belongs next
organisation's other web properties, without touching a single Hypha template.

Two themes are defined, `light` and `dark`, and visitors switch between them (or follow their
operating system) with the theme toggle in the footer. Whatever you change, change it in both.
operating system) with the theme toggle in the header. Whatever you change, change it in both.

## How the colours are organised

Expand Down Expand Up @@ -74,9 +74,16 @@ overriding that one value re-colours both themes at once.
Create the file `hypha/templates_custom/includes/head_end.html`.

This template is included at the very end of `<head>`, after Hypha's own stylesheets, and exists
precisely for additions like this. Anything you declare there wins over the defaults, because it
comes later in the cascade. Nothing needs to be recompiled and no `collectstatic` run is
needed — the change takes effect as soon as the file is in place.
precisely for additions like this. Anything you declare there wins over the defaults, for one of
two reasons: the daisyUI theme values are emitted inside a `@layer base` cascade layer, and
unlayered styles beat layered ones whatever their specificity or order; the brand ramp
(`--color-brand` and its shades) is emitted unlayered in `:root`, and your `:root` rule wins on
source order because it comes later in the document.

Both routes depend on your CSS staying **unlayered**, so do not wrap it in a cascade layer of
your own — `@layer custom { :root { --color-brand: … } }` would lose to the unlayered brand ramp
and be silently ignored. Nothing needs to be recompiled and no `collectstatic` run is needed —
the change takes effect as soon as the file is in place.

Everything below goes inside that file.

Expand Down Expand Up @@ -120,11 +127,15 @@ lower it towards `0` for more neutral ones.

To go further than the brand colour, override the daisyUI variables directly. These are set
*per theme*, so you must override them per theme too — otherwise your light-mode value leaks into
dark mode:
dark mode.

There are three cases to cover, not two. A visitor who has never touched the theme toggle is in
auto mode, and auto mode sets no `data-theme` attribute at all — it lets the operating system
decide, through a `prefers-color-scheme` media query:

```html
<style>
/* Light theme */
/* Light theme, and auto mode on a light OS */
:root,
[data-theme="light"] {
--color-primary: oklch(48% 0.11 162.8);
Expand All @@ -138,6 +149,15 @@ dark mode:
--color-primary-content: oklch(18% 0.02 162.8);
--color-secondary: oklch(64% 0.03 229);
}

/* Auto mode on a dark OS */
@media (prefers-color-scheme: dark) {
:root:not([data-theme]) {
--color-primary: oklch(70% 0.13 162.8);
--color-primary-content: oklch(18% 0.02 162.8);
--color-secondary: oklch(64% 0.03 229);
}
}
</style>
```

Expand All @@ -146,26 +166,45 @@ glare against the dark background.

!!! warning

Setting a per-theme variable on `:root` alone applies it to *both* themes. Hypha's own dark
theme rule has the same specificity but comes earlier in the stylesheet, so your later
declaration wins even when dark mode is active. `--color-brand` is the exception — it is not
part of either theme, so `:root` is the correct place for it.
Setting a per-theme variable on `:root` alone applies it to *every* case. Your declarations
are unlayered and Hypha's theme rules sit in `@layer base`, so yours win even where Hypha's
selector is the more specific one — a bare `:root` overrides the dark theme too.

Auto mode is the easiest of the three to miss, because `[data-theme="dark"]` cannot match a
visitor who has no `data-theme` attribute — but your `:root` block can. Leave the third block
out and an auto-mode visitor on a dark operating system gets Hypha's dark backgrounds with
your *light* colours on top. That is why the `@media (prefers-color-scheme: dark)` block above
repeats the dark values: both blocks are yours, and `:root:not([data-theme])` is more specific
than `:root`, so the dark values win in auto mode whichever order you write them in.

`--color-brand` is the exception — it is not part of either theme, so `:root` is the correct
place for it and it never needs repeating.

A softer, warmer set of backgrounds is a common second change. It tints every panel, table and
card in the application:
card in the application. This one changes the light theme only, so target light explicitly rather
than using `:root` — `:root` would carry the pale backgrounds into dark mode as well:

```html
<style>
:root,
[data-theme="light"] {
--color-base-100: oklch(99% 0.004 85); /* off-white page background */
--color-base-200: oklch(97% 0.006 85); /* panels and table headers */
--color-base-300: oklch(93% 0.008 85); /* borders and dividers */
}

/* Auto mode on a light OS */
@media (prefers-color-scheme: light) {
:root:not([data-theme]) {
--color-base-100: oklch(99% 0.004 85);
--color-base-200: oklch(97% 0.006 85);
--color-base-300: oklch(93% 0.008 85);
}
}
</style>
```

Squarer or rounder corners are a one-liner, and apply to both themes:
Squarer or rounder corners are a one-liner. These are meant to apply everywhere, so here `:root`
is exactly what you want — it covers auto mode on both kinds of operating system:

```html
<style>
Expand Down Expand Up @@ -244,7 +283,7 @@ other template — see [Overriding templates](overriding-templates.md).

## Before you go live

- **Check both themes.** Use the theme toggle in the footer, and check it in a browser set to
- **Check both themes.** Use the theme toggle in the header, and check it in a browser set to
dark mode as well.
- **Check the contrast.** Most colours have a matching `--color-*-content` for text drawn on top
of them, and the `base-*` backgrounds share `--color-base-content`. If you darken
Expand Down
156 changes: 105 additions & 51 deletions hypha/static_src/javascript/behaviours/theme-toggle.js
Original file line number Diff line number Diff line change
@@ -1,62 +1,116 @@
let prefersDark = window.matchMedia("(prefers-color-scheme: dark)").matches;
(function () {
let prefersDark = window.matchMedia("(prefers-color-scheme: dark)").matches;

function setTheme(mode) {
if (mode !== "light" && mode !== "dark" && mode !== "auto") {
console.error(`Got invalid theme mode: ${mode}. Resetting to auto.`);
mode = "auto";
/**
* Read the stored theme preference.
*
* Storage can be unavailable (blocked cookies, some private browsing modes)
* and then throws. This script runs blocking in <head>, 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;
}
}
document.documentElement.dataset.theme = mode;
localStorage.setItem("theme", mode);
}

function cycleTheme() {
const currentTheme = localStorage.getItem("theme") || "auto";

if (prefersDark) {
// Auto (dark) -> Light -> Dark
if (currentTheme === "auto") {
setTheme("light");
} else if (currentTheme === "light") {
setTheme("dark");
} else {
setTheme("auto");

/**
* Read the theme currently applied to the document.
*
* The DOM is authoritative here rather than localStorage, which can be
* unavailable and would then report "auto" on every click, leaving the toggle
* stuck on a single theme.
*
* @returns {string} "light", "dark" or "auto".
*/
function getCurrentTheme() {
return document.documentElement.dataset.theme || "auto";
}

/**
* Persist the theme preference, ignoring unavailable storage.
*
* @param {string} mode - "light", "dark" or "auto".
*/
function storeTheme(mode) {
try {
localStorage.setItem("theme", mode);
} catch (_e) {
// Nothing to do: the theme still applies for this page view.
}
} else {
// Auto (light) -> Dark -> Light
if (currentTheme === "auto") {
setTheme("dark");
} else if (currentTheme === "dark") {
setTheme("light");
}

function setTheme(mode) {
if (mode !== "light" && mode !== "dark" && mode !== "auto") {
console.error(`Got invalid theme mode: ${mode}. Resetting to auto.`);
mode = "auto";
}

// 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 {
setTheme("auto");
document.documentElement.dataset.theme = mode;
}

storeTheme(mode);
}
}

function initTheme() {
// set theme defined in localStorage if there is one, or fallback to auto mode
const currentTheme = localStorage.getItem("theme");
currentTheme ? setTheme(currentTheme) : setTheme("auto");
}

function setupTheme() {
// Attach event handlers for toggling themes
let buttons = document.getElementsByClassName("theme-toggle");
for (var i = 0; i < buttons.length; i++) {
buttons[i].addEventListener("click", cycleTheme);

function cycleTheme() {
const currentTheme = getCurrentTheme();

if (prefersDark) {
// Auto (dark) -> Light -> Dark
if (currentTheme === "auto") {
setTheme("light");
} else if (currentTheme === "light") {
setTheme("dark");
} else {
setTheme("auto");
}
} else {
// Auto (light) -> Dark -> Light
if (currentTheme === "auto") {
setTheme("dark");
} else if (currentTheme === "dark") {
setTheme("light");
} else {
setTheme("auto");
}
}
}
}

initTheme();
function initTheme() {
// set theme defined in localStorage if there is one, or fallback to auto mode
const currentTheme = getStoredTheme();
currentTheme ? setTheme(currentTheme) : setTheme("auto");
}

document.addEventListener("DOMContentLoaded", function () {
setupTheme();
});
initTheme();

// reset theme and release image if auto mode activated and os preferences have changed
window
.matchMedia("(prefers-color-scheme: dark)")
.addEventListener("change", function (e) {
prefersDark = e.matches;
initTheme();
// Delegated so the toggle keeps working after htmx swaps the header out, which
// hx-boost links without an hx-target do by replacing the whole <body>.
// It sees every click on the page, so closest() is guarded: a click event
// can be dispatched at a non-Element target, which has no closest().
document.addEventListener("click", function (e) {
if (e.target.closest?.(".theme-toggle")) {
cycleTheme();
}
});

// Auto mode carries no data-theme attribute, so the CSS follows the OS on its
// own and nothing needs re-applying when the OS preference changes. Only the
// order the toggle cycles through depends on it.
window
.matchMedia("(prefers-color-scheme: dark)")
.addEventListener("change", function (e) {
prefersDark = e.matches;
});
})();
21 changes: 17 additions & 4 deletions hypha/static_src/javascript/tinymce-dark-mode.js
Original file line number Diff line number Diff line change
Expand Up @@ -58,9 +58,11 @@
});
});

// The theme toggle sets data-theme on <html>. 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.
Expand All @@ -69,8 +71,19 @@
for (const editor of tinymce.get() ?? []) {
swapContentCss(editor, name);
}
}).observe(document.documentElement, {
}

// The theme toggle sets data-theme on <html> 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);
})();
4 changes: 2 additions & 2 deletions hypha/static_src/tailwind/components/theme-toggle.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

Expand All @@ -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;
}

Expand Down
21 changes: 21 additions & 0 deletions hypha/static_src/tailwind/main.css
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,27 @@
}
@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 anywhere above the element) on
a dark OS. In auto mode a scoped subtree that sets its own data-theme drops
out of the second branch, so <div data-theme="light"> is not painted as dark.
Under an explicit data-theme root the variant follows that root and does not
track nested overrides — "nearest data-theme ancestor wins" needs :has(), so
scoped themes inside an explicitly themed page are out of scope. Hypha only
ever sets data-theme on <html>. */
@custom-variant dark {
&:where([data-theme="dark"], [data-theme="dark"] *) {
@slot;
}

@media (prefers-color-scheme: dark) {
&:where(:not([data-theme]):not([data-theme] *)) {
@slot;
}
}
}

@theme {
--default-border-width: var(--border);

Expand Down
6 changes: 1 addition & 5 deletions hypha/templates/base-apply.html
Original file line number Diff line number Diff line change
Expand Up @@ -23,16 +23,12 @@
{% block user_menu %}
<div class="flex gap-2 print-hidden">

<button class="theme-toggle btn btn-circle btn-soft btn-secondary" data-tippy-content="{% trans 'Toggle color theme' %}">
<button type="button" class="theme-toggle btn btn-circle btn-soft btn-secondary" data-tippy-content="{% trans 'Toggle color theme' %}">
<div class="sr-only theme-label-when-auto">{% trans "Toggle theme" %} ({% trans "current theme" %}: auto)</div>
<div class="sr-only theme-label-when-light">{% trans "Toggle theme" %} ({% trans "current theme" %}: light)</div>
<div class="sr-only theme-label-when-dark">{% trans "Toggle theme" %} ({% trans "current theme" %}: dark)</div>


<div class="sr-only">{% trans "Toggle Light / Dark / Auto color theme" %}</div>

<svg aria-hidden="true" class="w-5 h-5 theme-icon-when-auto" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
<!-- <path d="M0 0h24v24H0z" fill="currentColor"></path> -->
<path fill="currentColor" d="M12 22C6.477 22 2 17.523 2 12S6.477 2 12 2s10 4.477 10 10-4.477 10-10 10zm0-2V4a8 8 0 1 0 0 16z"></path>
</svg>
{% heroicon_solid "moon" class="w-5 h-5 theme-icon-when-dark" aria_hidden="true" %}
Expand Down