3p1idvga1: Add dark and light design tokens with a persisted pre-paint theme

This commit is contained in:
2026-10-19 21:47:25 -06:00
parent c0f4260eb1
commit 4e9e05bc4e
7 changed files with 394 additions and 127 deletions
+51
View File
@@ -0,0 +1,51 @@
// Theme helper (ADR 0001, section 5). The theme is `data-theme` on <html>,
// stored in localStorage under THEME_KEY. Dark is the default.
export type Theme = "dark" | "light";
export const THEME_KEY = "theme";
export const DEFAULT_THEME: Theme = "dark";
type ReadableStorage = Pick<Storage, "getItem">;
type WritableStorage = Pick<Storage, "setItem">;
type ThemeRoot = { dataset: DOMStringMap; style: { colorScheme: string } };
export function isTheme(value: unknown): value is Theme {
return value === "dark" || value === "light";
}
/** The stored theme, or dark when nothing valid is stored or storage throws. */
export function readStoredTheme(storage: ReadableStorage | null | undefined): Theme {
try {
const value = storage?.getItem(THEME_KEY);
return isTheme(value) ? value : DEFAULT_THEME;
} catch {
return DEFAULT_THEME;
}
}
/** The theme currently on the root element, or dark when it is missing. */
export function currentTheme(root: ThemeRoot | null | undefined): Theme {
const value = root?.dataset.theme;
return isTheme(value) ? value : DEFAULT_THEME;
}
/**
* Set the theme on the root element and store it. A failed write still
* changes the theme for this session.
*/
export function applyTheme(theme: Theme, root: ThemeRoot, storage: WritableStorage | null | undefined): void {
root.dataset.theme = theme;
root.style.colorScheme = theme;
try {
storage?.setItem(THEME_KEY, theme);
} catch {
// Storage blocked or full: keep the theme for this session only.
}
}
/**
* Inline script for the first child of <head>. It runs before first paint, so
* a stored light theme never flashes dark. Fixed string, no user input.
*/
export const THEME_SCRIPT = `(function(){var t="${DEFAULT_THEME}";try{var s=localStorage.getItem("${THEME_KEY}");if(s==="light"||s==="dark")t=s;}catch(e){}var r=document.documentElement;r.dataset.theme=t;r.style.colorScheme=t;})();`;
+44
View File
@@ -0,0 +1,44 @@
"use client";
import { useCallback, useSyncExternalStore } from "react";
import { applyTheme, currentTheme, DEFAULT_THEME, THEME_KEY, type Theme } from "./theme";
const listeners = new Set<() => void>();
function subscribe(listener: () => void): () => void {
listeners.add(listener);
const onStorage = (event: StorageEvent) => {
// Another tab changed the theme: follow it.
if (event.key !== THEME_KEY) return;
const next = event.newValue === "light" || event.newValue === "dark" ? event.newValue : DEFAULT_THEME;
applyTheme(next, document.documentElement, null);
listeners.forEach((l) => l());
};
window.addEventListener("storage", onStorage);
return () => {
listeners.delete(listener);
window.removeEventListener("storage", onStorage);
};
}
const getSnapshot = (): Theme => currentTheme(document.documentElement);
const getServerSnapshot = (): Theme => DEFAULT_THEME;
function safeStorage(): Storage | null {
try {
return window.localStorage;
} catch {
return null;
}
}
/** Shared theme state for every toggle: the current theme and a setter. */
export function useTheme(): { theme: Theme; setTheme: (theme: Theme) => void; toggleTheme: () => void } {
const theme = useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot);
const setTheme = useCallback((next: Theme) => {
applyTheme(next, document.documentElement, safeStorage());
listeners.forEach((l) => l());
}, []);
const toggleTheme = useCallback(() => setTheme(getSnapshot() === "dark" ? "light" : "dark"), [setTheme]);
return { theme, setTheme, toggleTheme };
}