/**
* Light, dark, or whatever the operating system says — chosen once, remembered, and painted
* before the first frame.
*
* Nearly every app in the fleet had written this on its own, and each copy knew something the
* others did not. What they got wrong is the design brief for this file:
*
* - **Storage is read unguarded.** A browser that blocks site data throws on `localStorage`
* itself, and the read ran during a render, so a privacy setting was a white page. Apps fixed
* this one at a time, and the last copy still had it.
* - **The pre-paint script and the runtime disagreed.** Most apps kept two copies of "read the
* key, resolve system, set the class": an inline script in `index.html`, because it has to run
* before any module loads, and the store the app renders from. They drifted on exactly the
* inputs nobody tests. With storage blocked one forced light while the other followed the OS,
* and with a stale value in the key one read it as light while the other read it as system.
* Either way the page painted one theme, and React swapped it for the other one frame later.
* One app had written a build check that the two copies at least named the same key.
* - **The OS preference started as a guess.** A provider that holds the OS preference in state,
* starting at `"light"` and correcting it in an effect, commits a light frame first. That
* frame REMOVES the `.dark` class the pre-paint script had just set, so a reader whose OS is
* dark sees a flash on every load.
* - **The pre-paint script was inline, and a strict CSP blocks inline scripts.** Under
* `script-src 'self'` it never ran in production: the app booted light and turned dark a frame
* later. A CSP hash works until the first whitespace change silently breaks it again.
* - **Another tab's choice was ignored.** A tab left open kept the old theme until it reloaded.
* One app did listen for `storage`, then re-read its own page, which the other tab had not
* changed.
*
* So there is ONE function, {@link startTheme}. The page runs it before first paint and the app
* uses it after React mounts. `@gusnips/vite/theme` serves it as a same-origin FILE, built from
* this function's own source, so the two cannot drift and a strict CSP allows it. The runtime
* the pre-paint script creates is also the one the app talks to: {@link createTheme} finds it
* rather than starting a second one.
*
* What stays in the app: the control (three buttons, a menu, a switch) and its words.
*/
import { useSyncExternalStore } from "react";
/** What a person picked. `"system"` means "follow the operating system", live. */
export type ThemeMode = "light" | "dark" | "system";
/** What is on screen right now. */
export type ResolvedTheme = "light" | "dark";
export interface ThemeOptions {
/**
* The `localStorage` key holding the choice. Keep the key your app already uses: a new key
* resets everybody's choice once.
*/
key: string;
/**
* What an empty, unreadable or unrecognised key means. Defaults to `"system"`.
*/
defaultMode?: ThemeMode;
/**
* Where the resolved theme goes on ``. `"class"` toggles `dark`, which is what
* Tailwind's `dark:` variant and `@gusnips/tokens` read. `"data-theme"` writes `"light"` or
* `"dark"` into that attribute, for a stylesheet whose `@custom-variant` reads it. Defaults
* to `"class"`.
*/
attribute?: "class" | "data-theme";
/**
* The browser toolbar colour for each theme, written to every ``.
* Leave it out and the tags are not touched. It is needed whenever the choice can differ from
* the OS setting: a `media` query on the tag follows the OS, not your app.
*/
themeColor?: Readonly>;
}
export interface ThemeSnapshot {
readonly mode: ThemeMode;
readonly resolved: ResolvedTheme;
}
export interface ThemeController {
getSnapshot(): ThemeSnapshot;
/** Applies at once, and is remembered when storage allows it. */
setMode(mode: ThemeMode): void;
subscribe(listener: () => void): () => void;
}
declare global {
interface Window {
/** The page's one theme controller. See {@link startTheme}. */
__frontkitTheme?: ThemeController;
}
}
/**
* Reads the choice, paints it, and keeps it painted: when the OS switches (a laptop that goes
* dark at sunset), when another tab picks a theme, and when the page comes back from the
* back-forward cache.
*
* **This function is serialized.** `@gusnips/vite/theme` writes `startTheme.toString()` into the
* pre-paint file, so it must not reference anything outside its own body: no imports, no helpers
* or constants from this module. It would still pass here and throw a `ReferenceError` in that
* file, before first paint. The vite package's test runs the serialized copy in a bare context
* to catch that. Comments go up here rather than in the body, because the body ships.
*
* **One per page.** The first call stores the controller on `window` and later calls return it,
* whatever options they pass. Two would both follow the OS, and the one that missed a `setMode`
* would repaint its stale choice on the next change. So the pre-paint script and the app must
* pass the same options, which is easiest when both import them from one module.
*
* **Blocked storage is not an empty key.** When storage throws, the default applies at boot, and
* a choice made afterwards lasts until the page is left. A resync never replaces that choice
* with the default, because a read that failed says nothing about what was chosen.
*
* "system" is stored as the word, not as an empty key. An empty key means the default, and the
* default is not always "system".
*
* **It writes to the page when the theme changes, and at no other time.** A page may override the
* class for its own lifetime, as a public booking page that is always light does, and keeps that
* override until the theme really changes. Repainting on every resync would undo it a moment later,
* because `pageshow` fires on every load, not only on a back-forward restore.
*/
export function startTheme(options: ThemeOptions): ThemeController {
const running = window.__frontkitTheme;
if (running) return running;
const { key, defaultMode = "system", attribute = "class", themeColor } = options;
const media = window.matchMedia("(prefers-color-scheme: dark)");
const listeners = new Set<() => void>();
const isMode = (value: unknown): value is ThemeMode =>
value === "light" || value === "dark" || value === "system";
const read = (): ThemeMode | undefined => {
try {
const stored = window.localStorage.getItem(key);
return isMode(stored) ? stored : defaultMode;
} catch {
return undefined;
}
};
const resolve = (mode: ThemeMode): ResolvedTheme =>
mode === "system" ? (media.matches ? "dark" : "light") : mode;
const paint = (resolved: ResolvedTheme) => {
const root = document.documentElement;
if (attribute === "class") root.classList.toggle("dark", resolved === "dark");
else root.setAttribute(attribute, resolved);
if (themeColor)
document
.querySelectorAll('meta[name="theme-color"]')
.forEach((meta) => meta.setAttribute("content", themeColor[resolved]));
};
const initial = read() ?? defaultMode;
let snapshot: ThemeSnapshot = { mode: initial, resolved: resolve(initial) };
const apply = (mode: ThemeMode) => {
const resolved = resolve(mode);
if (resolved !== snapshot.resolved) paint(resolved);
if (mode === snapshot.mode && resolved === snapshot.resolved) return;
snapshot = { mode, resolved };
listeners.forEach((listener) => listener());
};
const sync = () => {
const stored = read();
if (stored) apply(stored);
};
paint(snapshot.resolved);
media.addEventListener("change", () => apply(snapshot.mode));
window.addEventListener("storage", (event) => {
if (event.key === key || event.key === null) sync();
});
window.addEventListener("pageshow", sync);
const controller: ThemeController = {
getSnapshot: () => snapshot,
setMode(mode) {
if (!isMode(mode)) return;
try {
window.localStorage.setItem(key, mode);
} catch {
// Blocked: the choice lasts until the page is left.
}
apply(mode);
},
subscribe(listener) {
listeners.add(listener);
return () => {
listeners.delete(listener);
};
},
};
window.__frontkitTheme = controller;
return controller;
}
export interface Theme {
/** The choice and what it resolves to, re-rendering when either changes. */
useTheme(): ThemeSnapshot & { setMode(mode: ThemeMode): void };
/** For code outside React: a keyboard shortcut, a command palette. Browser only. */
setMode(mode: ThemeMode): void;
/** For code outside React. Answers the prerender snapshot where there is no window. */
getSnapshot(): ThemeSnapshot;
}
/**
* The React side of {@link startTheme}. Nothing starts at import, so a module calling this at
* its top level is safe to prerender. The controller starts on first use, or is found if the
* pre-paint script already started it.
*
* During a prerender, and while hydrating, `useTheme` answers the default. `"system"` cannot be
* resolved without a window, so it reads as light, as `color-scheme` does before the pre-paint
* script runs. React then re-renders with the real snapshot, so a toggle hydrates cleanly and
* shows the right choice a frame later instead of failing to hydrate.
*/
export function createTheme(options: ThemeOptions): Theme {
const defaultMode = options.defaultMode ?? "system";
const serverSnapshot: ThemeSnapshot = {
mode: defaultMode,
resolved: defaultMode === "dark" ? "dark" : "light",
};
const subscribe = (listener: () => void) => startTheme(options).subscribe(listener);
const getSnapshot = () =>
typeof window === "undefined" ? serverSnapshot : startTheme(options).getSnapshot();
const getServerSnapshot = () => serverSnapshot;
const setMode = (mode: ThemeMode) => startTheme(options).setMode(mode);
function useTheme() {
return { ...useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot), setMode };
}
return { useTheme, setMode, getSnapshot };
}