217 lines
7.2 KiB
TypeScript
217 lines
7.2 KiB
TypeScript
"use client";
|
|
|
|
import {
|
|
createContext,
|
|
useCallback,
|
|
useContext,
|
|
useEffect,
|
|
useLayoutEffect,
|
|
useMemo,
|
|
useState,
|
|
} from "react";
|
|
import type { ReactNode } from "react";
|
|
|
|
import {
|
|
THEMES,
|
|
THEME_CLASSES,
|
|
DARK_SURFACES,
|
|
themeColorScheme,
|
|
type ThemeName,
|
|
type RawTheme,
|
|
} from "@/lib/themes";
|
|
|
|
// Re-exported so existing imports keep working.
|
|
export { THEMES, DARK_SURFACES };
|
|
export type { ThemeName, RawTheme };
|
|
|
|
/**
|
|
* App theme provider (replaces next-themes).
|
|
*
|
|
* next-themes injects its no-FOUC script as a <script> inside the React
|
|
* component tree, which React 19 (dev) rejects: "Encountered a script tag
|
|
* while rendering React component". Everything it does is small, so this
|
|
* provider implements the same API ({ theme, setTheme, resolvedTheme })
|
|
* directly, and the equivalent inline script lives in app/layout.tsx where
|
|
* it renders as plain server HTML and actually executes.
|
|
*
|
|
* Semantics (kept compatible for all existing consumers):
|
|
* - `theme` the stored preference: one of THEMES, or "system"
|
|
* - `resolvedTheme` the theme actually applied, always one of THEMES
|
|
* - html classes .theme-default | .theme-sunset | ... (see globals.css)
|
|
*
|
|
* Theme scoping (per-project themes):
|
|
* A subtree can temporarily override the global preference with
|
|
* `useThemeScope().setScope(name)` -- the project pages use this so a
|
|
* project's assigned theme applies while its page is open and lifts on
|
|
* navigation (see components/theme/project-theme-scope.tsx). While a scope
|
|
* is active it wins over `theme` for `resolvedTheme` and the DOM; the
|
|
* stored global preference is never touched, so changing the global theme
|
|
* from the theme menu while inside a scoped project is fine (it applies
|
|
* everywhere *except* the scoped page, which keeps its own theme).
|
|
* The provider also picks up a `data-project-theme` marker on <html> at
|
|
* mount -- set by the project page's no-FOUC inline script -- so a scoped
|
|
* theme is in effect from the very first frame of a hard page load.
|
|
*/
|
|
|
|
const STORAGE_KEY = "theme";
|
|
const ALL_CLASSES = Object.values(THEME_CLASSES);
|
|
|
|
interface ThemeContextValue {
|
|
theme: RawTheme;
|
|
resolvedTheme: ThemeName;
|
|
setTheme: (theme: RawTheme) => void;
|
|
themes: readonly ThemeName[];
|
|
}
|
|
|
|
interface ThemeScopeValue {
|
|
scope: ThemeName | null;
|
|
setScope: (scope: ThemeName | null) => void;
|
|
}
|
|
|
|
const ThemeContext = createContext<ThemeContextValue>({
|
|
theme: "system",
|
|
resolvedTheme: "default",
|
|
setTheme: () => {},
|
|
themes: THEMES,
|
|
});
|
|
const ThemeScopeContext = createContext<ThemeScopeValue>({
|
|
scope: null,
|
|
setScope: () => {},
|
|
});
|
|
|
|
function systemPrefersDark(): boolean {
|
|
return (
|
|
typeof window !== "undefined" &&
|
|
!!window.matchMedia?.("(prefers-color-scheme: dark)").matches
|
|
);
|
|
}
|
|
|
|
function resolve(theme: RawTheme): ThemeName {
|
|
return theme === "system" ? (systemPrefersDark() ? "dark" : "default") : theme;
|
|
}
|
|
|
|
/** Exported so consumers (e.g. isDarkTheme) derive the applied theme from
|
|
* the same source of truth the provider uses -- and stay reactive when it
|
|
* changes. */
|
|
export function resolveTheme(theme: RawTheme): ThemeName {
|
|
return resolve(theme);
|
|
}
|
|
|
|
function applyToDom(name: ThemeName) {
|
|
if (typeof document === "undefined") return;
|
|
const root = document.documentElement;
|
|
// Also drop "light": a class next-themes applied to <html> in earlier
|
|
// versions, harmless but stale.
|
|
root.classList.remove(...ALL_CLASSES, "light");
|
|
root.classList.add(THEME_CLASSES[name]);
|
|
root.style.colorScheme = themeColorScheme(name);
|
|
}
|
|
|
|
/** The project page's no-FOUC script leaves this marker behind (see
|
|
* themeSwitchScript in lib/themes.ts); read it once at mount so the first
|
|
* frame of a hard load is already scoped. */
|
|
function readInitialScope(): ThemeName | null {
|
|
if (typeof document === "undefined") return null;
|
|
const marker = document.documentElement.dataset.projectTheme;
|
|
return marker && (THEMES as readonly string[]).includes(marker)
|
|
? (marker as ThemeName)
|
|
: null;
|
|
}
|
|
|
|
export function ThemeProvider({
|
|
children,
|
|
defaultTheme = "system",
|
|
}: {
|
|
children: ReactNode;
|
|
defaultTheme?: RawTheme;
|
|
}) {
|
|
// Both the server and the first client render agree on `defaultTheme`
|
|
// (hydration-safe); the stored preference is picked up right after mount.
|
|
// The inline script in app/layout.tsx already applied the right classes
|
|
// before first paint, so there is no visible jump either way.
|
|
const [theme, setThemeState] = useState<RawTheme>(defaultTheme);
|
|
// Starts null (matching the server render); the project-page marker is
|
|
// applied in a layout effect below, before paint.
|
|
const [scope, setScope] = useState<ThemeName | null>(null);
|
|
|
|
const resolvedTheme: ThemeName = scope ?? resolve(theme);
|
|
|
|
useEffect(() => {
|
|
try {
|
|
const stored = localStorage.getItem(STORAGE_KEY);
|
|
if (stored) setThemeState(stored as RawTheme);
|
|
} catch {
|
|
/* storage unavailable -- stay on defaultTheme */
|
|
}
|
|
}, []);
|
|
|
|
// Pick up a project's no-FOUC marker (hard page load). Runs in the same
|
|
// pre-paint window as the apply effect below, so the global-theme flash
|
|
// it may cause is never visible. (Mount-time sync from an external
|
|
// source, same pattern as the localStorage read above.)
|
|
useLayoutEffect(() => {
|
|
const initialScope = readInitialScope();
|
|
if (initialScope) {
|
|
// eslint-disable-next-line react-hooks/set-state-in-effect -- mount-time sync from the <html> marker, see above
|
|
setScope(initialScope);
|
|
}
|
|
}, []);
|
|
|
|
// Keep the DOM in sync whenever the resolved theme changes -- synchronously
|
|
// (layout effect) so the CSS classes land in the same frame as the
|
|
// JS-driven colors that derive from this state: no flash of stale colors.
|
|
useLayoutEffect(() => {
|
|
applyToDom(resolvedTheme);
|
|
}, [resolvedTheme]);
|
|
|
|
// Follow the OS while in "system" mode (and nothing is scoped).
|
|
useEffect(() => {
|
|
if (scope || theme !== "system" || !window.matchMedia) return;
|
|
const mq = window.matchMedia("(prefers-color-scheme: dark)");
|
|
const onChange = () => applyToDom(resolve("system"));
|
|
mq.addEventListener("change", onChange);
|
|
return () => mq.removeEventListener("change", onChange);
|
|
}, [scope, theme]);
|
|
|
|
// Keep multiple tabs in sync.
|
|
useEffect(() => {
|
|
const onStorage = (e: StorageEvent) => {
|
|
if (e.key === STORAGE_KEY) setThemeState((e.newValue || defaultTheme) as RawTheme);
|
|
};
|
|
window.addEventListener("storage", onStorage);
|
|
return () => window.removeEventListener("storage", onStorage);
|
|
}, [defaultTheme]);
|
|
|
|
const setTheme = useCallback((t: RawTheme) => {
|
|
setThemeState(t);
|
|
try {
|
|
localStorage.setItem(STORAGE_KEY, t);
|
|
} catch {
|
|
/* ignore -- preference just won't persist */
|
|
}
|
|
}, []);
|
|
|
|
const value = useMemo<ThemeContextValue>(
|
|
() => ({ theme, resolvedTheme, setTheme, themes: THEMES }),
|
|
[theme, resolvedTheme, setTheme],
|
|
);
|
|
const scopeValue = useMemo<ThemeScopeValue>(
|
|
() => ({ scope, setScope }),
|
|
[scope],
|
|
);
|
|
|
|
return (
|
|
<ThemeScopeContext.Provider value={scopeValue}>
|
|
<ThemeContext.Provider value={value}>{children}</ThemeContext.Provider>
|
|
</ThemeScopeContext.Provider>
|
|
);
|
|
}
|
|
|
|
export function useTheme(): ThemeContextValue {
|
|
return useContext(ThemeContext);
|
|
}
|
|
|
|
export function useThemeScope(): ThemeScopeValue {
|
|
return useContext(ThemeScopeContext);
|
|
}
|