Add js web stuff to landing page + documentation
This commit is contained in:
146
go/jsruntime/uikit/Theme.tsx
Normal file
146
go/jsruntime/uikit/Theme.tsx
Normal file
@@ -0,0 +1,146 @@
|
||||
// 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>`;
|
||||
Reference in New Issue
Block a user