147 lines
5.7 KiB
TypeScript
147 lines
5.7 KiB
TypeScript
// The theme controller.
|
|
//
|
|
// The kit is themed by TOKENS, not by a `dark:` variant on every component (see
|
|
// styles/theme.css). Components say bg-surface / text-ink / border-line and never
|
|
// name a colour; a `.dark` class on <html> re-points what those tokens mean. So all
|
|
// this module does is put a class on an element — the four hundred class strings in
|
|
// the kit are none the wiser.
|
|
//
|
|
// The storage key is deliberately the SAME one the Go/WASM kit uses
|
|
// (webui.ThemeBootScript, webui.themeStorageKey). Both halves of kjol-web are served
|
|
// from one origin, so they share a localStorage: choose dark in the /wasm section,
|
|
// walk over to /js, and it is still dark. Two front-ends, one preference.
|
|
|
|
import { createSignal, onCleanup } from "solid-js";
|
|
import { Icon } from "./Icons.tsx";
|
|
|
|
export type ThemeMode = "system" | "light" | "dark";
|
|
|
|
export const THEME_STORAGE_KEY = "kjol-theme";
|
|
|
|
const prefersDark = (): boolean => {
|
|
try {
|
|
return window.matchMedia("(prefers-color-scheme: dark)").matches;
|
|
} catch {
|
|
return false; // SSR: no window. The boot script settles it in the browser.
|
|
}
|
|
};
|
|
|
|
const readMode = (): ThemeMode => {
|
|
try {
|
|
const m = localStorage.getItem(THEME_STORAGE_KEY);
|
|
if (m === "light" || m === "dark") return m;
|
|
} catch {
|
|
// Private mode, or SSR. "system" is the right answer in both.
|
|
}
|
|
return "system";
|
|
};
|
|
|
|
const [mode, setModeSignal] = createSignal<ThemeMode>(readMode());
|
|
|
|
// isDark is the RESOLVED answer — what "system" actually means right now — as opposed
|
|
// to `mode`, which is what the user asked for. The toggle needs the former; the
|
|
// three-way picker needs the latter. They are not the same question.
|
|
const [isDark, setIsDark] = createSignal(false);
|
|
|
|
const resolve = (m: ThemeMode): boolean => m === "dark" || (m === "system" && prefersDark());
|
|
|
|
function apply(m: ThemeMode) {
|
|
const dark = resolve(m);
|
|
setIsDark(dark);
|
|
try {
|
|
document.documentElement.classList.toggle("dark", dark);
|
|
} catch {
|
|
// SSR. Nothing to toggle; the boot script has already done it for real.
|
|
}
|
|
}
|
|
|
|
/** Set the theme and remember it. "system" forgets, rather than storing the word. */
|
|
export function setTheme(m: ThemeMode) {
|
|
setModeSignal(m);
|
|
try {
|
|
if (m === "system") localStorage.removeItem(THEME_STORAGE_KEY);
|
|
else localStorage.setItem(THEME_STORAGE_KEY, m);
|
|
} catch {
|
|
// Not fatal — the theme still applies for this page.
|
|
}
|
|
apply(m);
|
|
}
|
|
|
|
/** Flip between light and dark, resolving "system" to whatever it currently means. */
|
|
export function toggleTheme() {
|
|
setTheme(isDark() ? "light" : "dark");
|
|
}
|
|
|
|
// initTheme syncs this module's signals with the class the BOOT SCRIPT already put on
|
|
// <html>, and starts following the OS while the mode is "system".
|
|
//
|
|
// It does not cause the first paint — that already happened, correctly, before any of
|
|
// this code existed. Calling it late is therefore harmless; not calling it at all just
|
|
// means the toggle button starts out showing the wrong icon.
|
|
//
|
|
// Call it once, from a component (it registers a cleanup).
|
|
export function initTheme() {
|
|
apply(mode());
|
|
|
|
try {
|
|
const mq = window.matchMedia("(prefers-color-scheme: dark)");
|
|
const onChange = () => {
|
|
// Only while the user has expressed no preference. Once they have picked a
|
|
// side, the OS changing its mind is not an instruction.
|
|
if (mode() === "system") apply("system");
|
|
};
|
|
mq.addEventListener("change", onChange);
|
|
onCleanup(() => mq.removeEventListener("change", onChange));
|
|
} catch {
|
|
// SSR, or a browser too old for matchMedia events. Neither is worth a crash.
|
|
}
|
|
}
|
|
|
|
export function useTheme() {
|
|
return { mode, isDark, setTheme, toggleTheme };
|
|
}
|
|
|
|
interface ThemeToggleProps {
|
|
small?: boolean;
|
|
class?: string;
|
|
}
|
|
|
|
export function ThemeToggle(props: ThemeToggleProps) {
|
|
const size = () => (props.small ? 14 : 16);
|
|
|
|
return (
|
|
<button
|
|
type="button"
|
|
onclick={toggleTheme}
|
|
title={isDark() ? "Switch to light" : "Switch to dark"}
|
|
aria-label={isDark() ? "Switch to light theme" : "Switch to dark theme"}
|
|
class={
|
|
"inline-flex cursor-pointer items-center justify-center rounded-default border border-line bg-surface text-ink-soft transition hover:bg-surface-raised hover:text-ink " +
|
|
(props.small ? "h-7 w-7 " : "h-9 w-9 ") +
|
|
(props.class || "")
|
|
}
|
|
>
|
|
{/* Show the destination, not the current state: a moon means "go dark". A
|
|
button that displays what you already have gives you nothing to press. */}
|
|
<Icon icon={isDark() ? "sun" : "moon"} size={size()} />
|
|
</button>
|
|
);
|
|
}
|
|
|
|
// THEME_BOOT_SCRIPT is the inline script an app puts in its <head>, BEFORE any
|
|
// stylesheet or markup.
|
|
//
|
|
// It exists to prevent the flash. The server cannot read localStorage, so it cannot
|
|
// know which theme to render; if the class were applied by this module after the
|
|
// bundle loads, every dark-mode user would be shown a white page for as long as the
|
|
// JavaScript takes to arrive, and then have it yanked out from under them. This runs
|
|
// first, synchronously, and so the first paint is already correct.
|
|
//
|
|
// It is byte-for-byte equivalent to the Go/WASM kit's webui.ThemeBootScript, and reads
|
|
// the same key. A server that already emits that one does not need this.
|
|
export const THEME_BOOT_SCRIPT = `<script>(function(){try{
|
|
var m = localStorage.getItem("kjol-theme");
|
|
var dark = m === "dark" || (!m && matchMedia("(prefers-color-scheme: dark)").matches);
|
|
if (dark) document.documentElement.classList.add("dark");
|
|
}catch(e){}})();</script>`;
|