From d71b53b3c55ab0d58754d1838c166def60f26fa6 Mon Sep 17 00:00:00 2001 From: patrick Date: Tue, 11 Aug 2026 20:05:02 -0400 Subject: [PATCH] feat: add themeable design tokens with cookie-backed SSR --- frontend/src/app.css | 154 +++++---- frontend/src/app.d.ts | 4 +- frontend/src/app.html | 1 + frontend/src/hooks.server.ts | 27 +- frontend/src/lib/theme/presets.ts | 346 +++++++++++++++++++ frontend/src/lib/theme/theme.svelte.ts | 133 +++++++ frontend/src/routes/(root)/+layout.server.ts | 6 +- frontend/src/routes/(root)/+layout.svelte | 29 +- 8 files changed, 629 insertions(+), 71 deletions(-) create mode 100644 frontend/src/lib/theme/presets.ts create mode 100644 frontend/src/lib/theme/theme.svelte.ts 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 @@
- +