Files
CodePilot/src/lib/theme/loader.ts
T
7418andClaude Opus 4.6 4da188bfe5 feat: theme family system with 12 color palettes + NavRail redesign
Theme system:
- Two-layer architecture: mode (light/dark/system via next-themes) + theme family (color palette via CSS variables)
- 12 theme families: Default, GitHub, Rosé Pine, Tokyo Night, Everforest, Nord, Kanagawa, Night Owl, Poimandres, Vesper, Horizon, SynthWave '84
- Per-family code block highlighting for Prism, HLJS, and Shiki (no more generic oneLight/oneDark fallback)
- Anti-FOUC: synchronous script in <head> reads localStorage and sets data-theme-family before paint
- Theme JSON files in themes/ directory, auto-discovered by server-side loader
- OKLCH color space for all values; CSS specificity html[data-theme-family] beats :root

UI changes:
- NavRail: moved Settings to bottom (replacing theme toggle), removed light/dark toggle button
- Settings: merged General controls into one card with dividers; Appearance section with live Shiki code preview
- Replaced hardcoded blue-500/blue-600 with primary/primary-foreground across 19 components
- Appearance settings: pill mode selector (light/dark/system), theme family dropdown, live code preview

New files:
- src/lib/theme/{types,loader,render-css,context,code-themes}.ts — core theme infrastructure
- src/components/layout/ThemeFamilyProvider.tsx — client context provider
- src/components/settings/AppearanceSection.tsx — appearance settings UI
- src/hooks/useAppTheme.ts — combined theme hook
- themes/*.json — 12 theme family definitions
- docs/handover/theme-system.md — handover documentation

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 11:15:01 +08:00

215 lines
7.6 KiB
TypeScript

import fs from 'fs';
import path from 'path';
import type { ThemeFamily, ThemeFamilyMeta, ThemeColors } from './types';
/** Hardcoded fallback matching globals.css :root values. */
const FALLBACK_LIGHT: ThemeColors = {
background: 'oklch(1 0 0)',
foreground: 'oklch(0.147 0.004 49.25)',
card: 'oklch(1 0 0)',
cardForeground: 'oklch(0.147 0.004 49.25)',
popover: 'oklch(1 0 0)',
popoverForeground: 'oklch(0.147 0.004 49.25)',
primary: 'oklch(0.546 0.245 262.881)',
primaryForeground: 'oklch(0.985 0.001 106.423)',
secondary: 'oklch(0.97 0.001 106.424)',
secondaryForeground: 'oklch(0.216 0.006 56.043)',
muted: 'oklch(0.97 0.001 106.424)',
mutedForeground: 'oklch(0.553 0.013 58.071)',
accent: 'oklch(0.97 0.001 106.424)',
accentForeground: 'oklch(0.216 0.006 56.043)',
destructive: 'oklch(0.577 0.245 27.325)',
border: 'oklch(0.923 0.003 48.717)',
input: 'oklch(0.923 0.003 48.717)',
ring: 'oklch(0.546 0.245 262.881)',
chart1: 'oklch(0.546 0.245 262.881)',
chart2: 'oklch(0.6 0.118 184.704)',
chart3: 'oklch(0.398 0.07 227.392)',
chart4: 'oklch(0.828 0.189 84.429)',
chart5: 'oklch(0.769 0.188 70.08)',
sidebar: 'oklch(0.985 0.001 106.423)',
sidebarForeground: 'oklch(0.147 0.004 49.25)',
sidebarPrimary: 'oklch(0.546 0.245 262.881)',
sidebarPrimaryForeground: 'oklch(0.985 0.001 106.423)',
sidebarAccent: 'oklch(0.97 0.001 106.424)',
sidebarAccentForeground: 'oklch(0.216 0.006 56.043)',
sidebarBorder: 'oklch(0.923 0.003 48.717)',
sidebarRing: 'oklch(0.546 0.245 262.881)',
};
const FALLBACK_DARK: ThemeColors = {
background: 'oklch(0.147 0.004 49.25)',
foreground: 'oklch(0.985 0.001 106.423)',
card: 'oklch(0.216 0.006 56.043)',
cardForeground: 'oklch(0.985 0.001 106.423)',
popover: 'oklch(0.216 0.006 56.043)',
popoverForeground: 'oklch(0.985 0.001 106.423)',
primary: 'oklch(0.623 0.214 259.815)',
primaryForeground: 'oklch(0.985 0.001 106.423)',
secondary: 'oklch(0.268 0.007 34.298)',
secondaryForeground: 'oklch(0.985 0.001 106.423)',
muted: 'oklch(0.268 0.007 34.298)',
mutedForeground: 'oklch(0.709 0.01 56.259)',
accent: 'oklch(0.268 0.007 34.298)',
accentForeground: 'oklch(0.985 0.001 106.423)',
destructive: 'oklch(0.704 0.191 22.216)',
border: 'oklch(1 0 0 / 10%)',
input: 'oklch(1 0 0 / 15%)',
ring: 'oklch(0.623 0.214 259.815)',
chart1: 'oklch(0.623 0.214 259.815)',
chart2: 'oklch(0.696 0.17 162.48)',
chart3: 'oklch(0.769 0.188 70.08)',
chart4: 'oklch(0.627 0.265 303.9)',
chart5: 'oklch(0.645 0.246 16.439)',
sidebar: 'oklch(0.216 0.006 56.043)',
sidebarForeground: 'oklch(0.985 0.001 106.423)',
sidebarPrimary: 'oklch(0.623 0.214 259.815)',
sidebarPrimaryForeground: 'oklch(0.985 0.001 106.423)',
sidebarAccent: 'oklch(0.268 0.007 34.298)',
sidebarAccentForeground: 'oklch(0.985 0.001 106.423)',
sidebarBorder: 'oklch(1 0 0 / 10%)',
sidebarRing: 'oklch(0.623 0.214 259.815)',
};
/** Every key in ThemeColors is required — must match types.ts exactly. */
const REQUIRED_COLOR_KEYS: (keyof ThemeColors)[] = [
'background', 'foreground', 'card', 'cardForeground',
'popover', 'popoverForeground', 'primary', 'primaryForeground',
'secondary', 'secondaryForeground', 'muted', 'mutedForeground',
'accent', 'accentForeground', 'destructive', 'border', 'input', 'ring',
'chart1', 'chart2', 'chart3', 'chart4', 'chart5',
'sidebar', 'sidebarForeground', 'sidebarPrimary', 'sidebarPrimaryForeground',
'sidebarAccent', 'sidebarAccentForeground', 'sidebarBorder', 'sidebarRing',
];
function isValidThemeColors(obj: unknown): obj is ThemeColors {
if (!obj || typeof obj !== 'object') return false;
const record = obj as Record<string, unknown>;
return REQUIRED_COLOR_KEYS.every(
(key) => typeof record[key] === 'string' && record[key] !== ''
);
}
/** Check whether a value is a valid { light: string, dark: string } mapping. */
function isValidCodeThemeMapping(obj: unknown): boolean {
if (!obj || typeof obj !== 'object') return false;
const rec = obj as Record<string, unknown>;
return typeof rec.light === 'string' && rec.light !== '' &&
typeof rec.dark === 'string' && rec.dark !== '';
}
function isValidThemeFamily(obj: unknown): obj is ThemeFamily {
if (!obj || typeof obj !== 'object') return false;
const record = obj as Record<string, unknown>;
return (
typeof record.id === 'string' &&
typeof record.label === 'string' &&
typeof record.order === 'number' &&
isValidThemeColors(record.light) &&
isValidThemeColors(record.dark)
);
}
/**
* Strip invalid codeTheme / shikiTheme from a loaded theme.
* This way a bad mapping doesn't break the whole theme; consumers fall back to defaults.
*/
function sanitizeThemeMappings(theme: ThemeFamily): void {
if (theme.codeTheme !== undefined && !isValidCodeThemeMapping(theme.codeTheme)) {
console.warn(`[theme-loader] Stripping invalid codeTheme from theme "${theme.id}"`);
delete theme.codeTheme;
}
if (theme.shikiTheme !== undefined && !isValidCodeThemeMapping(theme.shikiTheme)) {
console.warn(`[theme-loader] Stripping invalid shikiTheme from theme "${theme.id}"`);
delete theme.shikiTheme;
}
}
/** Resolve the themes directory. Works for both dev and Electron packaged app. */
function resolveThemesDir(): string {
// In Electron packaged app, process.resourcesPath points to resources/
// and themes are at standalone/themes/ inside resources.
const electronPath = process.env.RESOURCES_PATH
? path.join(process.env.RESOURCES_PATH, 'standalone', 'themes')
: null;
const devPath = path.resolve(process.cwd(), 'themes');
if (electronPath && fs.existsSync(electronPath)) return electronPath;
return devPath;
}
// Module-level cache (runs once per process)
let cachedFamilies: ThemeFamily[] | null = null;
/** Reset the module-level cache. Test-only. */
export function _resetCache() {
cachedFamilies = null;
}
/** Load and validate all theme families from themes/*.json. */
export function getAllThemeFamilies(): ThemeFamily[] {
if (cachedFamilies) return cachedFamilies;
const themesDir = resolveThemesDir();
const families: ThemeFamily[] = [];
try {
if (!fs.existsSync(themesDir)) {
console.warn(`[theme-loader] Themes directory not found: ${themesDir}`);
} else {
const files = fs.readdirSync(themesDir).filter((f) => f.endsWith('.json'));
for (const file of files) {
try {
const raw = fs.readFileSync(path.join(themesDir, file), 'utf-8');
const parsed = JSON.parse(raw);
if (isValidThemeFamily(parsed)) {
sanitizeThemeMappings(parsed);
families.push(parsed);
} else {
console.warn(`[theme-loader] Skipping invalid theme file: ${file}`);
}
} catch (err) {
console.warn(`[theme-loader] Failed to parse ${file}:`, err);
}
}
}
} catch (err) {
console.warn('[theme-loader] Error reading themes directory:', err);
}
// Ensure a default family always exists
if (!families.some((f) => f.id === 'default')) {
families.push({
id: 'default',
label: 'Default',
order: 0,
light: FALLBACK_LIGHT,
dark: FALLBACK_DARK,
});
}
families.sort((a, b) => a.order - b.order);
cachedFamilies = families;
return families;
}
/** Lightweight metadata for client-side use. */
export function getThemeFamilyMetas(): ThemeFamilyMeta[] {
return getAllThemeFamilies().map((f) => ({
id: f.id,
label: f.label,
description: f.description,
previewColors: {
primaryLight: f.light.primary,
primaryDark: f.dark.primary,
accentLight: f.light.accent,
backgroundLight: f.light.background,
},
codeTheme: f.codeTheme,
shikiTheme: f.shikiTheme,
}));
}