diff --git a/frontend/src/app.css b/frontend/src/app.css
index 3a6fb71..1ea6b25 100644
--- a/frontend/src/app.css
+++ b/frontend/src/app.css
@@ -8,74 +8,100 @@
:root {
--radius: 0.625rem;
- --background: oklch(1 0 0);
- --foreground: oklch(0.129 0.042 264.695);
- --card: oklch(1 0 0);
- --card-foreground: oklch(0.129 0.042 264.695);
- --popover: oklch(1 0 0);
- --popover-foreground: oklch(0.129 0.042 264.695);
- --primary: oklch(0.208 0.042 265.755);
- --primary-foreground: oklch(0.984 0.003 247.858);
- --secondary: oklch(0.968 0.007 247.896);
- --secondary-foreground: oklch(0.208 0.042 265.755);
- --muted: oklch(0.968 0.007 247.896);
- --muted-foreground: oklch(0.554 0.046 257.417);
- --accent: oklch(0.968 0.007 247.896);
- --accent-foreground: oklch(0.208 0.042 265.755);
- --destructive: oklch(0.577 0.245 27.325);
- --border: oklch(0.929 0.013 255.508);
- --input: oklch(0.929 0.013 255.508);
- --ring: oklch(0.704 0.04 256.788);
- --chart-1: oklch(0.646 0.222 41.116);
- --chart-2: oklch(0.6 0.118 184.704);
- --chart-3: oklch(0.398 0.07 227.392);
- --chart-4: oklch(0.828 0.189 84.429);
- --chart-5: oklch(0.769 0.188 70.08);
- --sidebar: oklch(0.984 0.003 247.858);
- --sidebar-foreground: oklch(0.129 0.042 264.695);
- --sidebar-primary: oklch(0.208 0.042 265.755);
- --sidebar-primary-foreground: oklch(0.984 0.003 247.858);
- --sidebar-accent: oklch(0.968 0.007 247.896);
- --sidebar-accent-foreground: oklch(0.208 0.042 265.755);
- --sidebar-border: oklch(0.929 0.013 255.508);
- --sidebar-ring: oklch(0.704 0.04 256.788);
+
+ /* Typography — system stacks, so nothing depends on a CDN or a webfont build. */
+ --app-font-sans: system-ui, -apple-system, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
+ --app-font-serif: Georgia, 'Iowan Old Style', 'Times New Roman', serif;
+ --app-font-mono: ui-monospace, 'SF Mono', 'Cascadia Mono', Menlo, Consolas, monospace;
+
+ /* Reading Room — 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;
+
+ /* Semantic — deliberately not the accent, so state never reads as branding. */
+ --success: #2f7d4f;
+ --success-foreground: #f2f7f6;
+ --flag: #b08a1e;
+ --star: #c79a25;
+
+ --chart-1: #1f5f5b;
+ --chart-2: #2f7d4f;
+ --chart-3: #b08a1e;
+ --chart-4: #4c6b8a;
+ --chart-5: #a6402f;
+
+ --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: oklch(0.129 0.042 264.695);
- --foreground: oklch(0.984 0.003 247.858);
- --card: oklch(0.208 0.042 265.755);
- --card-foreground: oklch(0.984 0.003 247.858);
- --popover: oklch(0.208 0.042 265.755);
- --popover-foreground: oklch(0.984 0.003 247.858);
- --primary: oklch(0.929 0.013 255.508);
- --primary-foreground: oklch(0.208 0.042 265.755);
- --secondary: oklch(0.279 0.041 260.031);
- --secondary-foreground: oklch(0.984 0.003 247.858);
- --muted: oklch(0.279 0.041 260.031);
- --muted-foreground: oklch(0.704 0.04 256.788);
- --accent: oklch(0.279 0.041 260.031);
- --accent-foreground: oklch(0.984 0.003 247.858);
- --destructive: oklch(0.704 0.191 22.216);
- --border: oklch(1 0 0 / 10%);
- --input: oklch(1 0 0 / 15%);
- --ring: oklch(0.551 0.027 264.364);
- --chart-1: oklch(0.488 0.243 264.376);
- --chart-2: oklch(0.696 0.17 162.48);
- --chart-3: oklch(0.769 0.188 70.08);
- --chart-4: oklch(0.627 0.265 303.9);
- --chart-5: oklch(0.645 0.246 16.439);
- --sidebar: oklch(0.208 0.042 265.755);
- --sidebar-foreground: oklch(0.984 0.003 247.858);
- --sidebar-primary: oklch(0.488 0.243 264.376);
- --sidebar-primary-foreground: oklch(0.984 0.003 247.858);
- --sidebar-accent: oklch(0.279 0.041 260.031);
- --sidebar-accent-foreground: oklch(0.984 0.003 247.858);
- --sidebar-border: oklch(1 0 0 / 10%);
- --sidebar-ring: oklch(0.551 0.027 264.364);
+ /* Reading Room — 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;
+
+ --chart-1: #6fbab0;
+ --chart-2: #4fa97a;
+ --chart-3: #d4a63a;
+ --chart-4: #7f9dc0;
+ --chart-5: #e0796b;
+
+ --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;
}
@theme inline {
+ --font-sans: var(--app-font-sans);
+ --font-serif: var(--app-font-serif);
+ --font-mono: var(--app-font-mono);
--radius-sm: calc(var(--radius) - 4px);
--radius-md: calc(var(--radius) - 2px);
--radius-lg: var(--radius);
@@ -95,6 +121,10 @@
--color-accent: var(--accent);
--color-accent-foreground: var(--accent-foreground);
--color-destructive: var(--destructive);
+ --color-success: var(--success);
+ --color-success-foreground: var(--success-foreground);
+ --color-flag: var(--flag);
+ --color-star: var(--star);
--color-border: var(--border);
--color-input: var(--input);
--color-ring: var(--ring);
diff --git a/frontend/src/app.d.ts b/frontend/src/app.d.ts
index 6cafeb6..8b24ae2 100644
--- a/frontend/src/app.d.ts
+++ b/frontend/src/app.d.ts
@@ -2,7 +2,8 @@
// for information about these interfaces
import type { ApiClient } from '$lib/server/api';
-import type { User } from 'lucide-svelte';
+import type { User } from '$lib/server/auth';
+import type { ThemeConfig } from '$lib/theme/presets';
declare global {
namespace App {
@@ -11,6 +12,7 @@ declare global {
authToken: string | null;
api: ApiClient;
user: User;
+ theme: ThemeConfig;
}
// interface PageData {}
// interface PageState {}
diff --git a/frontend/src/app.html b/frontend/src/app.html
index 06a6722..9a35db9 100644
--- a/frontend/src/app.html
+++ b/frontend/src/app.html
@@ -4,6 +4,7 @@
%sveltekit.head%
+
%sveltekit.body%
diff --git a/frontend/src/hooks.server.ts b/frontend/src/hooks.server.ts
index f7ac519..af0d5c5 100644
--- a/frontend/src/hooks.server.ts
+++ b/frontend/src/hooks.server.ts
@@ -1,5 +1,6 @@
import { ApiClient } from '$lib/server/api';
import { validateToken } from '$lib/server/auth';
+import { THEME_COOKIE, parseThemeCookie, themeToCss } from '$lib/theme/presets';
import { redirect, type Handle } from '@sveltejs/kit';
import { sequence } from '@sveltejs/kit/hooks';
@@ -34,4 +35,28 @@ const protectedRoutesHandle: Handle = async ({ event, resolve }) => {
return resolve(event);
};
-export const handle = sequence(authHandle, protectedRoutesHandle);
+/**
+ * Inline the stored theme into the document head.
+ *
+ * The palette has to be in the very first byte of HTML the browser paints,
+ * otherwise every page load flashes the default theme before hydration swaps
+ * it. The `` placeholder in app.html is the injection point.
+ */
+const themeHandle: Handle = async ({ event, resolve }) => {
+ const config = parseThemeCookie(event.cookies.get(THEME_COOKIE));
+ event.locals.theme = config;
+
+ return resolve(event, {
+ // The placeholder comment is kept, not replaced. Svelte 5 uses HTML
+ // comments as hydration markers, so SvelteKit warns when a chunk comes
+ // back with fewer comments than it went in with — removing this one is
+ // enough to trip that check.
+ transformPageChunk: ({ html }) =>
+ html.replace(
+ '',
+ ``
+ )
+ });
+};
+
+export const handle = sequence(themeHandle, authHandle, protectedRoutesHandle);
diff --git a/frontend/src/lib/theme/presets.ts b/frontend/src/lib/theme/presets.ts
new file mode 100644
index 0000000..c2a4c8b
--- /dev/null
+++ b/frontend/src/lib/theme/presets.ts
@@ -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;
+
+export interface Preset {
+ id: string;
+ name: string;
+ description: string;
+ radius: string;
+ fonts: Record;
+ 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>;
+ 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 {
+ 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 , 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'))}}`
+ );
+}
diff --git a/frontend/src/lib/theme/theme.svelte.ts b/frontend/src/lib/theme/theme.svelte.ts
new file mode 100644
index 0000000..3495df5
--- /dev/null
+++ b/frontend/src/lib/theme/theme.svelte.ts
@@ -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 and persists the config to a cookie.
+ */
+export class ThemeState {
+ config = $state({ preset: DEFAULT_PRESET_ID });
+
+ /** Which palette the editor is currently writing to. */
+ readonly mode = $derived(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 .
+ * 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>(THEME_KEY);
+}
diff --git a/frontend/src/routes/(root)/+layout.server.ts b/frontend/src/routes/(root)/+layout.server.ts
index 15cdb65..3e700f6 100644
--- a/frontend/src/routes/(root)/+layout.server.ts
+++ b/frontend/src/routes/(root)/+layout.server.ts
@@ -1,9 +1,11 @@
import { listLibraries } from '$lib/api/library.remote.js';
-export const load = async ({ depends }) => {
+export const load = async ({ depends, locals }) => {
depends('app:libraries');
return {
- libraries: await listLibraries({ pageSize: 100 })
+ libraries: await listLibraries({ pageSize: 100 }),
+ theme: locals.theme,
+ user: locals.user
};
};
diff --git a/frontend/src/routes/(root)/+layout.svelte b/frontend/src/routes/(root)/+layout.svelte
index a8d216e..6e7a831 100644
--- a/frontend/src/routes/(root)/+layout.svelte
+++ b/frontend/src/routes/(root)/+layout.svelte
@@ -8,17 +8,36 @@
import { setBookOperationsState } from '$lib/state/bookOperations.svelte';
import { setBookshelfState } from '$lib/state/bookshelf.svelte';
import { setLibraryState } from '$lib/state/library.svelte.js';
+ import { setThemeState } from '$lib/theme/theme.svelte';
+ import type { ThemeConfig } from '$lib/theme/presets';
import { ModeWatcher } from 'mode-watcher';
- import type { Snippet } from 'svelte';
+ import { untrack, type Snippet } from 'svelte';
import { Toaster } from 'svelte-sonner';
- let { data, children }: { data: { libraries: PaginatedResponse }; children: Snippet } =
- $props();
+ let {
+ data,
+ children
+ }: {
+ data: { libraries: PaginatedResponse; theme: ThemeConfig; user: { email: string } };
+ children: Snippet;
+ } = $props();
- const libraryState = setLibraryState(data.libraries.items);
+ // These states are seeded once and then own their own data — LibraryState has
+ // updateLibraries() for later syncing, and ThemeState owns the config after
+ // init. `untrack` says that explicitly, instead of silently capturing the
+ // initial value and warning about it.
+ const libraryState = setLibraryState(untrack(() => data.libraries.items));
const bookshelfState = setBookshelfState();
const bookOps = setBookOperationsState(libraryState.activeLibrary!.id);
+ const theme = setThemeState(untrack(() => data.theme));
+
+ // Inline custom properties live on :root and so are mode-blind. When the
+ // light/dark switch flips, rewrite them for the mode now showing.
+ $effect(() => {
+ theme.mode;
+ theme.syncMode();
+ });
@@ -27,7 +46,7 @@