feat: add themeable design tokens with cookie-backed SSR
This commit is contained in:
@@ -0,0 +1,133 @@
|
||||
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);
|
||||
}
|
||||
Reference in New Issue
Block a user