feat: add themeable design tokens with cookie-backed SSR
This commit is contained in:
+92
-62
@@ -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);
|
||||||
|
|||||||
Vendored
+3
-1
@@ -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 {}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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';
|
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
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
Reference in New Issue
Block a user