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
+92 -62
View File
@@ -8,74 +8,100 @@
:root { :root {
--radius: 0.625rem; --radius: 0.625rem;
--background: oklch(1 0 0);
--foreground: oklch(0.129 0.042 264.695); /* Typography — system stacks, so nothing depends on a CDN or a webfont build. */
--card: oklch(1 0 0); --app-font-sans: system-ui, -apple-system, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
--card-foreground: oklch(0.129 0.042 264.695); --app-font-serif: Georgia, 'Iowan Old Style', 'Times New Roman', serif;
--popover: oklch(1 0 0); --app-font-mono: ui-monospace, 'SF Mono', 'Cascadia Mono', Menlo, Consolas, monospace;
--popover-foreground: oklch(0.129 0.042 264.695);
--primary: oklch(0.208 0.042 265.755); /* Reading Room — light */
--primary-foreground: oklch(0.984 0.003 247.858); --background: #e9ebee;
--secondary: oklch(0.968 0.007 247.896); --foreground: #1b1f24;
--secondary-foreground: oklch(0.208 0.042 265.755); --card: #fbfbfc;
--muted: oklch(0.968 0.007 247.896); --card-foreground: #1b1f24;
--muted-foreground: oklch(0.554 0.046 257.417); --popover: #fbfbfc;
--accent: oklch(0.968 0.007 247.896); --popover-foreground: #1b1f24;
--accent-foreground: oklch(0.208 0.042 265.755); --primary: #1f5f5b;
--destructive: oklch(0.577 0.245 27.325); --primary-foreground: #f2f7f6;
--border: oklch(0.929 0.013 255.508); --secondary: #dfe3e8;
--input: oklch(0.929 0.013 255.508); --secondary-foreground: #1b1f24;
--ring: oklch(0.704 0.04 256.788); --muted: #e2e5e9;
--chart-1: oklch(0.646 0.222 41.116); --muted-foreground: #7d858f;
--chart-2: oklch(0.6 0.118 184.704); --accent: #d7e5e3;
--chart-3: oklch(0.398 0.07 227.392); --accent-foreground: #164743;
--chart-4: oklch(0.828 0.189 84.429); --destructive: #a6402f;
--chart-5: oklch(0.769 0.188 70.08); --border: #d2d6dc;
--sidebar: oklch(0.984 0.003 247.858); --input: #d2d6dc;
--sidebar-foreground: oklch(0.129 0.042 264.695); --ring: #1f5f5b;
--sidebar-primary: oklch(0.208 0.042 265.755);
--sidebar-primary-foreground: oklch(0.984 0.003 247.858); /* Semantic — deliberately not the accent, so state never reads as branding. */
--sidebar-accent: oklch(0.968 0.007 247.896); --success: #2f7d4f;
--sidebar-accent-foreground: oklch(0.208 0.042 265.755); --success-foreground: #f2f7f6;
--sidebar-border: oklch(0.929 0.013 255.508); --flag: #b08a1e;
--sidebar-ring: oklch(0.704 0.04 256.788); --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 { .dark {
--background: oklch(0.129 0.042 264.695); /* Reading Room — dark */
--foreground: oklch(0.984 0.003 247.858); --background: #15191b;
--card: oklch(0.208 0.042 265.755); --foreground: #e6eae9;
--card-foreground: oklch(0.984 0.003 247.858); --card: #1d2226;
--popover: oklch(0.208 0.042 265.755); --card-foreground: #e6eae9;
--popover-foreground: oklch(0.984 0.003 247.858); --popover: #1d2226;
--primary: oklch(0.929 0.013 255.508); --popover-foreground: #e6eae9;
--primary-foreground: oklch(0.208 0.042 265.755); --primary: #6fbab0;
--secondary: oklch(0.279 0.041 260.031); --primary-foreground: #0e1a19;
--secondary-foreground: oklch(0.984 0.003 247.858); --secondary: #232a2d;
--muted: oklch(0.279 0.041 260.031); --secondary-foreground: #e6eae9;
--muted-foreground: oklch(0.704 0.04 256.788); --muted: #232a2d;
--accent: oklch(0.279 0.041 260.031); --muted-foreground: #78868a;
--accent-foreground: oklch(0.984 0.003 247.858); --accent: #1b3330;
--destructive: oklch(0.704 0.191 22.216); --accent-foreground: #9fd8d0;
--border: oklch(1 0 0 / 10%); --destructive: #e0796b;
--input: oklch(1 0 0 / 15%); --border: #2a3034;
--ring: oklch(0.551 0.027 264.364); --input: #2a3034;
--chart-1: oklch(0.488 0.243 264.376); --ring: #6fbab0;
--chart-2: oklch(0.696 0.17 162.48);
--chart-3: oklch(0.769 0.188 70.08); --success: #4fa97a;
--chart-4: oklch(0.627 0.265 303.9); --success-foreground: #0e1a19;
--chart-5: oklch(0.645 0.246 16.439); --flag: #d4a63a;
--sidebar: oklch(0.208 0.042 265.755); --star: #e5b84b;
--sidebar-foreground: oklch(0.984 0.003 247.858);
--sidebar-primary: oklch(0.488 0.243 264.376); --chart-1: #6fbab0;
--sidebar-primary-foreground: oklch(0.984 0.003 247.858); --chart-2: #4fa97a;
--sidebar-accent: oklch(0.279 0.041 260.031); --chart-3: #d4a63a;
--sidebar-accent-foreground: oklch(0.984 0.003 247.858); --chart-4: #7f9dc0;
--sidebar-border: oklch(1 0 0 / 10%); --chart-5: #e0796b;
--sidebar-ring: oklch(0.551 0.027 264.364);
--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 { @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-sm: calc(var(--radius) - 4px);
--radius-md: calc(var(--radius) - 2px); --radius-md: calc(var(--radius) - 2px);
--radius-lg: var(--radius); --radius-lg: var(--radius);
@@ -95,6 +121,10 @@
--color-accent: var(--accent); --color-accent: var(--accent);
--color-accent-foreground: var(--accent-foreground); --color-accent-foreground: var(--accent-foreground);
--color-destructive: var(--destructive); --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-border: var(--border);
--color-input: var(--input); --color-input: var(--input);
--color-ring: var(--ring); --color-ring: var(--ring);
+3 -1
View File
@@ -2,7 +2,8 @@
// for information about these interfaces // for information about these interfaces
import type { ApiClient } from '$lib/server/api'; 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 { declare global {
namespace App { namespace App {
@@ -11,6 +12,7 @@ declare global {
authToken: string | null; authToken: string | null;
api: ApiClient; api: ApiClient;
user: User; user: User;
theme: ThemeConfig;
} }
// interface PageData {} // interface PageData {}
// interface PageState {} // interface PageState {}
+1
View File
@@ -4,6 +4,7 @@
<meta charset="utf-8" /> <meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" /> <meta name="viewport" content="width=device-width, initial-scale=1" />
%sveltekit.head% %sveltekit.head%
<!--theme-->
</head> </head>
<body data-sveltekit-preload-data="hover" class="overflow-hidden"> <body data-sveltekit-preload-data="hover" class="overflow-hidden">
<div style="display: contents">%sveltekit.body%</div> <div style="display: contents">%sveltekit.body%</div>
+26 -1
View File
@@ -1,5 +1,6 @@
import { ApiClient } from '$lib/server/api'; import { ApiClient } from '$lib/server/api';
import { validateToken } from '$lib/server/auth'; import { validateToken } from '$lib/server/auth';
import { THEME_COOKIE, parseThemeCookie, themeToCss } from '$lib/theme/presets';
import { redirect, type Handle } from '@sveltejs/kit'; import { redirect, type Handle } from '@sveltejs/kit';
import { sequence } from '@sveltejs/kit/hooks'; import { sequence } from '@sveltejs/kit/hooks';
@@ -34,4 +35,28 @@ const protectedRoutesHandle: Handle = async ({ event, resolve }) => {
return resolve(event); 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 `<!--theme-->` 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(
'<!--theme-->',
`<!--theme--><style id="chitai-theme">${themeToCss(config)}</style>`
)
});
};
export const handle = sequence(themeHandle, authHandle, protectedRoutesHandle);
+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);
}
+4 -2
View File
@@ -1,9 +1,11 @@
import { listLibraries } from '$lib/api/library.remote.js'; import { listLibraries } from '$lib/api/library.remote.js';
export const load = async ({ depends }) => { export const load = async ({ depends, locals }) => {
depends('app:libraries'); depends('app:libraries');
return { return {
libraries: await listLibraries({ pageSize: 100 }) libraries: await listLibraries({ pageSize: 100 }),
theme: locals.theme,
user: locals.user
}; };
}; };
+24 -5
View File
@@ -8,17 +8,36 @@
import { setBookOperationsState } from '$lib/state/bookOperations.svelte'; import { setBookOperationsState } from '$lib/state/bookOperations.svelte';
import { setBookshelfState } from '$lib/state/bookshelf.svelte'; import { setBookshelfState } from '$lib/state/bookshelf.svelte';
import { setLibraryState } from '$lib/state/library.svelte.js'; 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 { ModeWatcher } from 'mode-watcher';
import type { Snippet } from 'svelte'; import { untrack, type Snippet } from 'svelte';
import { Toaster } from 'svelte-sonner'; import { Toaster } from 'svelte-sonner';
let { data, children }: { data: { libraries: PaginatedResponse<Library> }; children: Snippet } = let {
$props(); data,
children
}: {
data: { libraries: PaginatedResponse<Library>; 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 bookshelfState = setBookshelfState();
const bookOps = setBookOperationsState(libraryState.activeLibrary!.id); 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();
});
</script> </script>
<Toaster /> <Toaster />
@@ -27,7 +46,7 @@
<div class="[--header-height:calc(--spacing(14))]"> <div class="[--header-height:calc(--spacing(14))]">
<Sidebar.Provider class="flex flex-col"> <Sidebar.Provider class="flex flex-col">
<div class="flex h-screen"> <div class="flex h-screen">
<AppSidebar /> <AppSidebar user={data.user} />
<Sidebar.Inset class="flex min-w-0 flex-1 flex-col overflow-x-hidden"> <Sidebar.Inset class="flex min-w-0 flex-1 flex-col overflow-x-hidden">
<SiteHeader /> <SiteHeader />
<div class="mt-4 flex-1 overflow-hidden p-4 pt-0"> <div class="mt-4 flex-1 overflow-hidden p-4 pt-0">