134 lines
3.8 KiB
TypeScript
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);
|
|
}
|