Feat: Adoptive theme foundation
This commit is contained in:
129
Frontend/src/theme/runtime.ts
Normal file
129
Frontend/src/theme/runtime.ts
Normal file
@@ -0,0 +1,129 @@
|
||||
// Path: Frontend/src/theme/runtime.ts
|
||||
|
||||
import { defaultThemePresetPath } from "./presets";
|
||||
import { createCssVariableMap, isThemeModeName, validateThemeDefinition, type ThemeDefinition, type ThemeModeName } from "./schema";
|
||||
|
||||
export type Theme = ThemeModeName;
|
||||
|
||||
export const THEME_STORAGE_KEY = "theme";
|
||||
export const DEFAULT_THEME: Theme = "light";
|
||||
|
||||
let activeThemeDefinition: ThemeDefinition | null = null;
|
||||
let themeInitializationPromise: Promise<ThemeDefinition | null> | null = null;
|
||||
|
||||
const canUseDom = (): boolean => typeof document !== "undefined";
|
||||
|
||||
const canUseStorage = (): boolean => typeof localStorage !== "undefined";
|
||||
|
||||
const canUseMatchMedia = (): boolean => typeof window !== "undefined" && typeof window.matchMedia === "function";
|
||||
|
||||
const getRootElement = (): HTMLElement | null => {
|
||||
return canUseDom() ? document.documentElement : null;
|
||||
};
|
||||
|
||||
const setDocumentThemeMode = (theme: Theme): void => {
|
||||
const rootElement = getRootElement();
|
||||
|
||||
rootElement?.setAttribute("data-theme", theme);
|
||||
|
||||
if (rootElement) {
|
||||
rootElement.style.colorScheme = theme;
|
||||
}
|
||||
|
||||
if (canUseStorage()) {
|
||||
localStorage.setItem(THEME_STORAGE_KEY, theme);
|
||||
}
|
||||
};
|
||||
|
||||
const applyThemeVariables = (themeDefinition: ThemeDefinition, theme: Theme): void => {
|
||||
const rootElement = getRootElement();
|
||||
|
||||
if (!rootElement) {
|
||||
return;
|
||||
}
|
||||
|
||||
const variableMap = createCssVariableMap(themeDefinition, theme);
|
||||
|
||||
for (const [name, value] of Object.entries(variableMap)) {
|
||||
rootElement.style.setProperty(name, value);
|
||||
}
|
||||
};
|
||||
|
||||
export const resolvePreferredTheme = (): Theme => {
|
||||
const stored = canUseStorage() ? localStorage.getItem(THEME_STORAGE_KEY) : null;
|
||||
|
||||
if (isThemeModeName(stored)) {
|
||||
return stored;
|
||||
}
|
||||
|
||||
if (canUseMatchMedia()) {
|
||||
return window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : DEFAULT_THEME;
|
||||
}
|
||||
|
||||
return DEFAULT_THEME;
|
||||
};
|
||||
|
||||
export const getDocumentTheme = (): Theme => {
|
||||
const theme = getRootElement()?.getAttribute("data-theme");
|
||||
|
||||
return isThemeModeName(theme) ? theme : resolvePreferredTheme();
|
||||
};
|
||||
|
||||
export const applyThemeDefinition = (themeDefinition: ThemeDefinition, theme: Theme): void => {
|
||||
activeThemeDefinition = themeDefinition;
|
||||
setDocumentThemeMode(theme);
|
||||
applyThemeVariables(themeDefinition, theme);
|
||||
};
|
||||
|
||||
export const initializeThemeRuntime = async (): Promise<ThemeDefinition | null> => {
|
||||
if (typeof window === "undefined") {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (activeThemeDefinition) {
|
||||
applyThemeDefinition(activeThemeDefinition, getDocumentTheme());
|
||||
return activeThemeDefinition;
|
||||
}
|
||||
|
||||
if (!themeInitializationPromise) {
|
||||
themeInitializationPromise = (async (): Promise<ThemeDefinition | null> => {
|
||||
try {
|
||||
const response = await fetch(defaultThemePresetPath, {
|
||||
headers: {
|
||||
Accept: "application/json",
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Theme preset request failed with status ${response.status}.`);
|
||||
}
|
||||
|
||||
const candidate = (await response.json()) as unknown;
|
||||
const result = validateThemeDefinition(candidate);
|
||||
|
||||
if (!result.success) {
|
||||
throw new Error(result.errors.join(" "));
|
||||
}
|
||||
|
||||
applyThemeDefinition(result.data, getDocumentTheme());
|
||||
|
||||
return result.data;
|
||||
} catch (error) {
|
||||
console.error("Failed to initialize theme runtime.", error);
|
||||
return null;
|
||||
} finally {
|
||||
themeInitializationPromise = null;
|
||||
}
|
||||
})();
|
||||
}
|
||||
|
||||
return themeInitializationPromise;
|
||||
};
|
||||
|
||||
export const setTheme = (theme: Theme): void => {
|
||||
setDocumentThemeMode(theme);
|
||||
|
||||
if (activeThemeDefinition) {
|
||||
applyThemeVariables(activeThemeDefinition, theme);
|
||||
}
|
||||
};
|
||||
Reference in New Issue
Block a user