feat: add themeable design tokens with cookie-backed SSR
This commit is contained in:
@@ -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'))}}`
|
||||
);
|
||||
}
|
||||
@@ -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