feat: add themeable design tokens with cookie-backed SSR

This commit is contained in:
2026-08-11 20:08:53 -04:00
parent a1f39a8dc8
commit d71b53b3c5
8 changed files with 629 additions and 71 deletions
+346
View File
@@ -0,0 +1,346 @@
/**
* Theme presets and the definition of what the editor may change.
*
* A preset supplies a full palette for both modes. The editor writes sparse
* overrides on top, per mode, so customising the dark palette never disturbs
* the light one.
*/
export type Mode = 'light' | 'dark';
/** CSS custom properties the editor exposes, in the order it shows them. */
export const COLOR_TOKENS = [
{ key: 'background', label: 'Background', hint: 'Page behind everything' },
{ key: 'foreground', label: 'Text', hint: 'Default body text' },
{ key: 'card', label: 'Surface', hint: 'Cards, rows, popovers' },
{ key: 'primary', label: 'Primary', hint: 'Buttons and active states' },
{ key: 'muted-foreground', label: 'Muted text', hint: 'Counts, captions, metadata' },
{ key: 'border', label: 'Border', hint: 'Rules and outlines' },
{ key: 'sidebar', label: 'Sidebar', hint: 'Sidebar and toolbar ground' },
{ key: 'success', label: 'Finished', hint: 'Completed reading progress' },
{ key: 'flag', label: 'Active filter', hint: 'The dot on Filter and Sort' },
{ key: 'star', label: 'Favourite', hint: 'Stars and selection rings' },
{ key: 'destructive', label: 'Destructive', hint: 'Delete actions' }
] as const;
export type ColorTokenKey = (typeof COLOR_TOKENS)[number]['key'];
export const FONT_TOKENS = [
{ key: 'app-font-sans', label: 'Interface' },
{ key: 'app-font-serif', label: 'Titles' },
{ key: 'app-font-mono', label: 'Numbers and labels' }
] as const;
export type FontTokenKey = (typeof FONT_TOKENS)[number]['key'];
/** System stacks only — no webfont fetch, so nothing can silently fall back. */
export const FONT_STACKS: { label: string; value: string }[] = [
{
label: 'System UI',
value: "system-ui, -apple-system, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif"
},
{ label: 'Georgia', value: "Georgia, 'Iowan Old Style', 'Times New Roman', serif" },
{ label: 'Times', value: "'Times New Roman', Times, serif" },
{ label: 'Palatino', value: "'Palatino Linotype', Palatino, 'Book Antiqua', serif" },
{ label: 'Helvetica', value: "'Helvetica Neue', Helvetica, Arial, sans-serif" },
{ label: 'Verdana', value: 'Verdana, Geneva, sans-serif' },
{ label: 'Monospace', value: "ui-monospace, 'SF Mono', 'Cascadia Mono', Menlo, Consolas, monospace" }
];
export type Palette = Record<string, string>;
export interface Preset {
id: string;
name: string;
description: string;
radius: string;
fonts: Record<FontTokenKey, string>;
light: Palette;
dark: Palette;
}
const SANS = FONT_STACKS[0].value;
const SERIF = FONT_STACKS[1].value;
const MONO = FONT_STACKS[6].value;
export const READING_ROOM: Preset = {
id: 'reading-room',
name: 'Reading Room',
description: 'Cool grey paper, one teal accent, serif titles.',
radius: '0.625rem',
fonts: { 'app-font-sans': SANS, 'app-font-serif': SERIF, 'app-font-mono': MONO },
light: {
background: '#e9ebee',
foreground: '#1b1f24',
card: '#fbfbfc',
'card-foreground': '#1b1f24',
popover: '#fbfbfc',
'popover-foreground': '#1b1f24',
primary: '#1f5f5b',
'primary-foreground': '#f2f7f6',
secondary: '#dfe3e8',
'secondary-foreground': '#1b1f24',
muted: '#e2e5e9',
'muted-foreground': '#7d858f',
accent: '#d7e5e3',
'accent-foreground': '#164743',
destructive: '#a6402f',
border: '#d2d6dc',
input: '#d2d6dc',
ring: '#1f5f5b',
success: '#2f7d4f',
'success-foreground': '#f2f7f6',
flag: '#b08a1e',
star: '#c79a25',
sidebar: '#e2e5e9',
'sidebar-foreground': '#1b1f24',
'sidebar-primary': '#1f5f5b',
'sidebar-primary-foreground': '#f2f7f6',
'sidebar-accent': '#d7e5e3',
'sidebar-accent-foreground': '#164743',
'sidebar-border': '#d2d6dc',
'sidebar-ring': '#1f5f5b'
},
dark: {
background: '#15191b',
foreground: '#e6eae9',
card: '#1d2226',
'card-foreground': '#e6eae9',
popover: '#1d2226',
'popover-foreground': '#e6eae9',
primary: '#6fbab0',
'primary-foreground': '#0e1a19',
secondary: '#232a2d',
'secondary-foreground': '#e6eae9',
muted: '#232a2d',
'muted-foreground': '#78868a',
accent: '#1b3330',
'accent-foreground': '#9fd8d0',
destructive: '#e0796b',
border: '#2a3034',
input: '#2a3034',
ring: '#6fbab0',
success: '#4fa97a',
'success-foreground': '#0e1a19',
flag: '#d4a63a',
star: '#e5b84b',
sidebar: '#111517',
'sidebar-foreground': '#e6eae9',
'sidebar-primary': '#6fbab0',
'sidebar-primary-foreground': '#0e1a19',
'sidebar-accent': '#1b3330',
'sidebar-accent-foreground': '#9fd8d0',
'sidebar-border': '#2a3034',
'sidebar-ring': '#6fbab0'
}
};
export const STACKS: Preset = {
id: 'stacks',
name: 'Stacks',
description: 'Warm near-black with brass. Covers do the talking.',
radius: '0.25rem',
fonts: { 'app-font-sans': SANS, 'app-font-serif': SERIF, 'app-font-mono': MONO },
light: {
...READING_ROOM.light,
background: '#f2efe9',
foreground: '#221e1a',
card: '#fbf9f5',
'card-foreground': '#221e1a',
popover: '#fbf9f5',
'popover-foreground': '#221e1a',
primary: '#8a6a15',
'primary-foreground': '#fbf9f5',
secondary: '#e7e1d6',
'secondary-foreground': '#221e1a',
muted: '#e7e1d6',
'muted-foreground': '#7d7266',
accent: '#efe6d2',
'accent-foreground': '#5c4708',
border: '#ddd5c7',
input: '#ddd5c7',
ring: '#8a6a15',
flag: '#8a6a15',
star: '#a8801d',
sidebar: '#ebe6dc',
'sidebar-foreground': '#221e1a',
'sidebar-primary': '#8a6a15',
'sidebar-primary-foreground': '#fbf9f5',
'sidebar-accent': '#efe6d2',
'sidebar-accent-foreground': '#5c4708',
'sidebar-border': '#ddd5c7',
'sidebar-ring': '#8a6a15'
},
dark: {
...READING_ROOM.dark,
background: '#141210',
foreground: '#ede7de',
card: '#221e1a',
'card-foreground': '#ede7de',
popover: '#221e1a',
'popover-foreground': '#ede7de',
primary: '#c9a227',
'primary-foreground': '#17130a',
secondary: '#2c2621',
'secondary-foreground': '#ede7de',
muted: '#2c2621',
'muted-foreground': '#7d7266',
accent: '#352c14',
'accent-foreground': '#e6c452',
border: '#2c2621',
input: '#2c2621',
ring: '#c9a227',
flag: '#c9a227',
star: '#e6c452',
sidebar: '#100e0c',
'sidebar-foreground': '#ede7de',
'sidebar-primary': '#c9a227',
'sidebar-primary-foreground': '#17130a',
'sidebar-accent': '#352c14',
'sidebar-accent-foreground': '#e6c452',
'sidebar-border': '#2c2621',
'sidebar-ring': '#c9a227'
}
};
export const SLATE: Preset = {
id: 'slate',
name: 'Slate',
description: 'The original shadcn palette, kept for comparison.',
radius: '0.625rem',
fonts: { 'app-font-sans': SANS, 'app-font-serif': SANS, 'app-font-mono': MONO },
light: {
...READING_ROOM.light,
background: '#ffffff',
foreground: '#020617',
card: '#ffffff',
'card-foreground': '#020617',
popover: '#ffffff',
'popover-foreground': '#020617',
primary: '#1e293b',
'primary-foreground': '#f8fafc',
secondary: '#f1f5f9',
'secondary-foreground': '#1e293b',
muted: '#f1f5f9',
'muted-foreground': '#64748b',
accent: '#f1f5f9',
'accent-foreground': '#1e293b',
destructive: '#dc2626',
border: '#e2e8f0',
input: '#e2e8f0',
ring: '#94a3b8',
success: '#16a34a',
flag: '#eab308',
star: '#fde047',
sidebar: '#f8fafc',
'sidebar-foreground': '#020617',
'sidebar-primary': '#1e293b',
'sidebar-primary-foreground': '#f8fafc',
'sidebar-accent': '#f1f5f9',
'sidebar-accent-foreground': '#1e293b',
'sidebar-border': '#e2e8f0',
'sidebar-ring': '#94a3b8'
},
dark: {
...READING_ROOM.dark,
background: '#020617',
foreground: '#f8fafc',
card: '#1e293b',
'card-foreground': '#f8fafc',
popover: '#1e293b',
'popover-foreground': '#f8fafc',
primary: '#e2e8f0',
'primary-foreground': '#1e293b',
secondary: '#334155',
'secondary-foreground': '#f8fafc',
muted: '#334155',
'muted-foreground': '#94a3b8',
accent: '#334155',
'accent-foreground': '#f8fafc',
destructive: '#f87171',
border: '#334155',
input: '#334155',
ring: '#64748b',
success: '#4ade80',
flag: '#eab308',
star: '#fde047',
sidebar: '#1e293b',
'sidebar-foreground': '#f8fafc',
'sidebar-primary': '#60a5fa',
'sidebar-primary-foreground': '#f8fafc',
'sidebar-accent': '#334155',
'sidebar-accent-foreground': '#f8fafc',
'sidebar-border': '#334155',
'sidebar-ring': '#64748b'
}
};
export const PRESETS: Preset[] = [READING_ROOM, STACKS, SLATE];
export const DEFAULT_PRESET_ID = READING_ROOM.id;
export function getPreset(id: string | undefined): Preset {
return PRESETS.find((p) => p.id === id) ?? READING_ROOM;
}
/** What we persist in the cookie. Sparse by design — presets carry the rest. */
export interface ThemeConfig {
preset: string;
radius?: string;
fonts?: Partial<Record<FontTokenKey, string>>;
light?: Palette;
dark?: Palette;
}
export const THEME_COOKIE = 'chitai-theme';
export function parseThemeCookie(raw: string | undefined | null): ThemeConfig {
if (!raw) return { preset: DEFAULT_PRESET_ID };
try {
const parsed = JSON.parse(decodeURIComponent(raw));
if (!parsed || typeof parsed !== 'object') return { preset: DEFAULT_PRESET_ID };
return { ...parsed, preset: typeof parsed.preset === 'string' ? parsed.preset : DEFAULT_PRESET_ID };
} catch {
return { preset: DEFAULT_PRESET_ID };
}
}
/** Merge a config over its preset to get the palette actually in force. */
export function resolvePalette(config: ThemeConfig, mode: Mode): Palette {
const preset = getPreset(config.preset);
return { ...preset[mode], ...(config[mode] ?? {}) };
}
export function resolveRadius(config: ThemeConfig): string {
return config.radius ?? getPreset(config.preset).radius;
}
export function resolveFonts(config: ThemeConfig): Record<FontTokenKey, string> {
return { ...getPreset(config.preset).fonts, ...(config.fonts ?? {}) };
}
/**
* Render a theme as CSS. Used on the server to inline the theme into the
* document head, so the first paint is already correct.
*
* The doubled `:root:root` is deliberate. app.css declares the same custom
* properties on plain `:root`, which has identical specificity, so whichever
* stylesheet comes last would win — and in dev Vite appends app.css to the end
* of <head>, after this tag. Doubling the selector raises specificity to
* (0,2,0) so the stored theme wins on merit rather than on load order.
*/
export function themeToCss(config: ThemeConfig): string {
const decl = (palette: Palette) =>
Object.entries(palette)
.map(([k, v]) => `--${k}:${v};`)
.join('');
const fonts = Object.entries(resolveFonts(config))
.map(([k, v]) => `--${k}:${v};`)
.join('');
return (
`:root:root{--radius:${resolveRadius(config)};${fonts}${decl(resolvePalette(config, 'light'))}}` +
`:root:root.dark{${decl(resolvePalette(config, 'dark'))}}`
);
}
+133
View File
@@ -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);
}