feat: add themeable design tokens with cookie-backed SSR
This commit is contained in:
+92
-62
@@ -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);
|
||||
|
||||
Vendored
+3
-1
@@ -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 {}
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
%sveltekit.head%
|
||||
<!--theme-->
|
||||
</head>
|
||||
<body data-sveltekit-preload-data="hover" class="overflow-hidden">
|
||||
<div style="display: contents">%sveltekit.body%</div>
|
||||
|
||||
@@ -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 `<!--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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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
|
||||
};
|
||||
};
|
||||
|
||||
@@ -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<Library> }; children: Snippet } =
|
||||
$props();
|
||||
let {
|
||||
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 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>
|
||||
|
||||
<Toaster />
|
||||
@@ -27,7 +46,7 @@
|
||||
<div class="[--header-height:calc(--spacing(14))]">
|
||||
<Sidebar.Provider class="flex flex-col">
|
||||
<div class="flex h-screen">
|
||||
<AppSidebar />
|
||||
<AppSidebar user={data.user} />
|
||||
<Sidebar.Inset class="flex min-w-0 flex-1 flex-col overflow-x-hidden">
|
||||
<SiteHeader />
|
||||
<div class="mt-4 flex-1 overflow-hidden p-4 pt-0">
|
||||
|
||||
Reference in New Issue
Block a user