Files
chitai/frontend/src/lib/theme/theme.svelte.ts
T

134 lines
3.8 KiB
TypeScript

import { getContext, setContext } from 'svelte';
import { browser } from '$app/environment';
import { mode } from 'mode-watcher';
import {
DEFAULT_PRESET_ID,
THEME_COOKIE,
getPreset,
resolveFonts,
resolvePalette,
resolveRadius,
type ColorTokenKey,
type FontTokenKey,
type Mode,
type ThemeConfig
} from './presets';
/**
* Live theme state.
*
* The server has already inlined the stored theme into the document head, so
* this class only takes over once the user edits something: it writes the
* changed custom properties onto <html> and persists the config to a cookie.
*/
export class ThemeState {
config = $state<ThemeConfig>({ preset: DEFAULT_PRESET_ID });
/** Which palette the editor is currently writing to. */
readonly mode = $derived<Mode>(mode.current === 'dark' ? 'dark' : 'light');
readonly preset = $derived(getPreset(this.config.preset));
readonly palette = $derived(resolvePalette(this.config, this.mode));
readonly radius = $derived(resolveRadius(this.config));
readonly fonts = $derived(resolveFonts(this.config));
readonly isCustomised = $derived(
Boolean(
this.config.radius ||
this.config.fonts ||
Object.keys(this.config.light ?? {}).length ||
Object.keys(this.config.dark ?? {}).length
)
);
/**
* True once this session has written inline custom properties onto <html>.
* Until then the server-injected stylesheet is the only source of theme, and
* it already covers both modes — so we touch nothing and there is no repaint.
*/
private touched = false;
constructor(config: ThemeConfig) {
this.config = config;
}
setPreset(id: string) {
// Switching preset discards overrides; keeping them across palettes
// produces colour combinations nobody chose.
this.config = { preset: id };
this.apply();
}
setColor(token: ColorTokenKey | string, value: string) {
const mode = this.mode;
this.config = { ...this.config, [mode]: { ...(this.config[mode] ?? {}), [token]: value } };
this.apply();
}
setRadius(value: string) {
this.config = { ...this.config, radius: value };
this.apply();
}
setFont(token: FontTokenKey, value: string) {
this.config = { ...this.config, fonts: { ...(this.config.fonts ?? {}), [token]: value } };
this.apply();
}
reset() {
this.config = { preset: this.config.preset };
this.apply();
}
/** Push the resolved theme onto the document and persist it. */
apply() {
if (!browser) return;
this.touched = true;
const root = document.documentElement;
root.style.setProperty('--radius', this.radius);
for (const [key, value] of Object.entries(this.fonts)) {
root.style.setProperty(`--${key}`, value);
}
// Inline styles sit on :root and so apply in both modes. Write the
// palette for the mode being displayed and rewrite it on mode change.
for (const [key, value] of Object.entries(this.palette)) {
root.style.setProperty(`--${key}`, value);
}
this.persist();
}
/**
* Re-apply after a light/dark switch, since inline vars are mode-blind.
* No-op until something has actually been edited — see `touched`.
*/
syncMode() {
if (!browser || !this.touched) return;
const palette = resolvePalette(this.config, this.mode);
for (const [key, value] of Object.entries(palette)) {
document.documentElement.style.setProperty(`--${key}`, value);
}
}
private persist() {
if (!browser) return;
const value = encodeURIComponent(JSON.stringify(this.config));
// One year, root path, lax — same shape as the auth cookie minus httpOnly,
// because the editor has to be able to read and rewrite it client-side.
document.cookie = `${THEME_COOKIE}=${value};path=/;max-age=31536000;samesite=lax`;
}
}
const THEME_KEY = Symbol('THEME');
export function setThemeState(config: ThemeConfig) {
return setContext(THEME_KEY, new ThemeState(config));
}
export function getThemeState() {
return getContext<ReturnType<typeof setThemeState>>(THEME_KEY);
}