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>
This commit is contained in:
7418andClaude Opus 4.6 committed 2026-03-09 11:15:01 +08:00
1 parent 7ad66f82c4
commit 4da188bfe5
53 files changed
+2446 -144

No files matched your search

+193
View File
@@ -0,0 +1,193 @@
/**
* Unit tests for theme family loader — tests the real public functions.
*
* Run with: npx tsx --test src/__tests__/unit/theme-loader.test.ts
*
* Strategy: stub process.cwd() to a temp dir and call _resetCache() before
* each test to get fresh loader results.
*/
import { describe, it, beforeEach, afterEach } from 'node:test';
import assert from 'node:assert/strict';
import fs from 'fs';
import path from 'path';
import os from 'os';
import { getAllThemeFamilies, getThemeFamilyMetas, _resetCache } from '../../lib/theme/loader';
/** Helper to create a minimal valid ThemeColors object. */
function makeColors(base: string) {
return {
background: base, foreground: base, card: base, cardForeground: base,
popover: base, popoverForeground: base, primary: base, primaryForeground: base,
secondary: base, secondaryForeground: base, muted: base, mutedForeground: base,
accent: base, accentForeground: base, destructive: base, border: base,
input: base, ring: base, chart1: base, chart2: base, chart3: base,
chart4: base, chart5: base, sidebar: base, sidebarForeground: base,
sidebarPrimary: base, sidebarPrimaryForeground: base, sidebarAccent: base,
sidebarAccentForeground: base, sidebarBorder: base, sidebarRing: base,
};
}
function makeTheme(id: string, order: number, color = 'oklch(0.5 0 0)') {
return { id, label: id, order, light: makeColors(color), dark: makeColors(color) };
}
let tmpDir: string;
const origCwd = process.cwd;
describe('theme-loader: getAllThemeFamilies', () => {
beforeEach(() => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'theme-loader-test-'));
fs.mkdirSync(path.join(tmpDir, 'themes'));
_resetCache();
process.cwd = () => tmpDir;
});
afterEach(() => {
process.cwd = origCwd;
_resetCache();
fs.rmSync(tmpDir, { recursive: true, force: true });
});
it('loads valid theme files and sorts by order', () => {
fs.writeFileSync(path.join(tmpDir, 'themes', 'b.json'), JSON.stringify(makeTheme('beta', 2)));
fs.writeFileSync(path.join(tmpDir, 'themes', 'a.json'), JSON.stringify(makeTheme('alpha', 1)));
fs.writeFileSync(path.join(tmpDir, 'themes', 'default.json'), JSON.stringify(makeTheme('default', 0)));
const families = getAllThemeFamilies();
assert.equal(families.length, 3);
assert.equal(families[0].id, 'default');
assert.equal(families[1].id, 'alpha');
assert.equal(families[2].id, 'beta');
});
it('skips invalid JSON files and still loads valid ones', () => {
fs.writeFileSync(path.join(tmpDir, 'themes', 'good.json'), JSON.stringify(makeTheme('good', 1)));
fs.writeFileSync(path.join(tmpDir, 'themes', 'bad.json'), 'not valid json {{{');
fs.writeFileSync(path.join(tmpDir, 'themes', 'incomplete.json'), JSON.stringify({ id: 'incomplete', label: 'X' }));
const families = getAllThemeFamilies();
const ids = families.map(f => f.id);
assert.ok(ids.includes('good'), 'valid theme loaded');
assert.ok(ids.includes('default'), 'default synthesized');
assert.ok(!ids.includes('incomplete'), 'incomplete theme skipped');
});
it('synthesizes default when no default.json exists', () => {
fs.writeFileSync(path.join(tmpDir, 'themes', 'custom.json'), JSON.stringify(makeTheme('custom', 5)));
const families = getAllThemeFamilies();
assert.ok(families.some(f => f.id === 'default'), 'default family exists');
assert.equal(families[0].id, 'default', 'default sorts first');
assert.equal(families[0].light.primary, 'oklch(0.546 0.245 262.881)', 'uses hardcoded fallback');
});
it('handles missing themes directory gracefully', () => {
fs.rmSync(path.join(tmpDir, 'themes'), { recursive: true });
const families = getAllThemeFamilies();
assert.equal(families.length, 1);
assert.equal(families[0].id, 'default');
});
it('ignores non-JSON files', () => {
fs.writeFileSync(path.join(tmpDir, 'themes', 'readme.md'), '# Themes');
fs.writeFileSync(path.join(tmpDir, 'themes', 'valid.json'), JSON.stringify(makeTheme('valid', 1)));
const families = getAllThemeFamilies();
assert.equal(families.length, 2); // valid + synthesized default
});
it('rejects themes missing chart/sidebar color keys', () => {
// Create a theme with only the old required keys (no chart*, no sidebar*)
const partialColors = {
background: 'x', foreground: 'x', card: 'x', cardForeground: 'x',
popover: 'x', popoverForeground: 'x', primary: 'x', primaryForeground: 'x',
secondary: 'x', secondaryForeground: 'x', muted: 'x', mutedForeground: 'x',
accent: 'x', accentForeground: 'x', destructive: 'x', border: 'x',
input: 'x', ring: 'x',
};
const partial = { id: 'partial', label: 'Partial', order: 1, light: partialColors, dark: partialColors };
fs.writeFileSync(path.join(tmpDir, 'themes', 'partial.json'), JSON.stringify(partial));
const families = getAllThemeFamilies();
assert.ok(!families.some(f => f.id === 'partial'), 'theme missing chart/sidebar keys is rejected');
});
it('strips invalid codeTheme/shikiTheme but keeps the theme', () => {
const theme = {
...makeTheme('fixable', 1),
codeTheme: { light: 123, dark: 'oneDark' }, // light is wrong type
shikiTheme: 'not-an-object', // completely wrong
};
fs.writeFileSync(path.join(tmpDir, 'themes', 'fixable.json'), JSON.stringify(theme));
const families = getAllThemeFamilies();
const loaded = families.find(f => f.id === 'fixable');
assert.ok(loaded, 'theme itself is still loaded');
assert.equal(loaded!.codeTheme, undefined, 'invalid codeTheme stripped');
assert.equal(loaded!.shikiTheme, undefined, 'invalid shikiTheme stripped');
});
it('caches results across calls', () => {
fs.writeFileSync(path.join(tmpDir, 'themes', 'default.json'), JSON.stringify(makeTheme('default', 0)));
const first = getAllThemeFamilies();
// Add another file after first load
fs.writeFileSync(path.join(tmpDir, 'themes', 'extra.json'), JSON.stringify(makeTheme('extra', 1)));
const second = getAllThemeFamilies();
assert.strictEqual(first, second, 'returns same reference from cache');
assert.equal(second.length, 1, 'does not pick up new files without cache reset');
});
});
describe('theme-loader: getThemeFamilyMetas', () => {
beforeEach(() => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'theme-meta-test-'));
fs.mkdirSync(path.join(tmpDir, 'themes'));
_resetCache();
process.cwd = () => tmpDir;
});
afterEach(() => {
process.cwd = origCwd;
_resetCache();
fs.rmSync(tmpDir, { recursive: true, force: true });
});
it('returns lightweight meta with preview colors and codeTheme', () => {
const theme = {
...makeTheme('test', 0, 'oklch(0.5 0.1 200)'),
description: 'A test theme',
codeTheme: { light: 'oneLight', dark: 'oneDark' },
shikiTheme: { light: 'one-light', dark: 'one-dark-pro' },
};
fs.writeFileSync(path.join(tmpDir, 'themes', 'test.json'), JSON.stringify(theme));
const metas = getThemeFamilyMetas();
const testMeta = metas.find(m => m.id === 'test');
assert.ok(testMeta, 'meta exists');
assert.equal(testMeta!.label, 'test');
assert.equal(testMeta!.description, 'A test theme');
assert.ok(testMeta!.previewColors, 'has preview colors');
assert.equal(testMeta!.previewColors!.primaryLight, 'oklch(0.5 0.1 200)');
assert.ok(testMeta!.codeTheme, 'has codeTheme');
assert.equal(testMeta!.codeTheme!.dark, 'oneDark');
assert.ok(testMeta!.shikiTheme, 'has shikiTheme');
assert.equal(testMeta!.shikiTheme!.dark, 'one-dark-pro');
});
it('meta does not include full color data', () => {
fs.writeFileSync(path.join(tmpDir, 'themes', 'default.json'), JSON.stringify(makeTheme('default', 0)));
const metas = getThemeFamilyMetas();
const meta = metas[0] as unknown as Record<string, unknown>;
assert.ok(!('light' in meta), 'no light colors in meta');
assert.ok(!('dark' in meta), 'no dark colors in meta');
assert.ok(!('order' in meta), 'no order in meta');
});
});
+107
View File
@@ -0,0 +1,107 @@
/**
* Unit tests for theme family CSS renderer.
*
* Run with: npx tsx --test src/__tests__/unit/theme-render-css.test.ts
*/
import { describe, it } from 'node:test';
import assert from 'node:assert/strict';
import { colorKeyToCssVar, renderThemeFamilyCSS } from '../../lib/theme/render-css';
import type { ThemeFamily } from '../../lib/theme/types';
describe('colorKeyToCssVar', () => {
it('should convert simple keys', () => {
assert.equal(colorKeyToCssVar('background'), '--background');
assert.equal(colorKeyToCssVar('foreground'), '--foreground');
assert.equal(colorKeyToCssVar('primary'), '--primary');
});
it('should convert camelCase to kebab-case', () => {
assert.equal(colorKeyToCssVar('cardForeground'), '--card-foreground');
assert.equal(colorKeyToCssVar('primaryForeground'), '--primary-foreground');
assert.equal(colorKeyToCssVar('mutedForeground'), '--muted-foreground');
assert.equal(colorKeyToCssVar('sidebarPrimaryForeground'), '--sidebar-primary-foreground');
});
it('should handle numbered keys', () => {
assert.equal(colorKeyToCssVar('chart1'), '--chart-1');
assert.equal(colorKeyToCssVar('chart5'), '--chart-5');
});
});
describe('renderThemeFamilyCSS', () => {
const makeColors = (val: string) => ({
background: val,
foreground: val,
card: val,
cardForeground: val,
popover: val,
popoverForeground: val,
primary: val,
primaryForeground: val,
secondary: val,
secondaryForeground: val,
muted: val,
mutedForeground: val,
accent: val,
accentForeground: val,
destructive: val,
border: val,
input: val,
ring: val,
chart1: val,
chart2: val,
chart3: val,
chart4: val,
chart5: val,
sidebar: val,
sidebarForeground: val,
sidebarPrimary: val,
sidebarPrimaryForeground: val,
sidebarAccent: val,
sidebarAccentForeground: val,
sidebarBorder: val,
sidebarRing: val,
});
const testFamily: ThemeFamily = {
id: 'test',
label: 'Test',
order: 0,
light: makeColors('oklch(1 0 0)'),
dark: makeColors('oklch(0.1 0 0)'),
};
it('should generate light and dark blocks', () => {
const css = renderThemeFamilyCSS([testFamily]);
assert.ok(css.includes('html[data-theme-family="test"]'));
assert.ok(css.includes('html.dark[data-theme-family="test"]'));
});
it('should include correct CSS variable assignments', () => {
const css = renderThemeFamilyCSS([testFamily]);
assert.ok(css.includes('--background: oklch(1 0 0);'));
assert.ok(css.includes('--background: oklch(0.1 0 0);'));
assert.ok(css.includes('--card-foreground:'));
assert.ok(css.includes('--sidebar-primary-foreground:'));
});
it('should handle multiple families', () => {
const family2: ThemeFamily = {
id: 'second',
label: 'Second',
order: 1,
light: makeColors('oklch(0.5 0 0)'),
dark: makeColors('oklch(0.2 0 0)'),
};
const css = renderThemeFamilyCSS([testFamily, family2]);
assert.ok(css.includes('html[data-theme-family="test"]'));
assert.ok(css.includes('html[data-theme-family="second"]'));
assert.ok(css.includes('html.dark[data-theme-family="second"]'));
});
it('should return empty string for empty input', () => {
const css = renderThemeFamilyCSS([]);
assert.equal(css, '');
});
});
+10 -10
View File
@@ -123,12 +123,12 @@
@apply bg-background text-foreground;
}
/* Selection color */
/* Selection color — derives from primary */
::selection {
background: oklch(0.7 0.15 260 / 30%);
background: color-mix(in oklch, var(--primary) 30%, transparent);
}
.dark ::selection {
background: oklch(0.6 0.2 260 / 35%);
background: color-mix(in oklch, var(--primary) 35%, transparent);
}
/* Custom scrollbar */
@@ -140,17 +140,17 @@
background: transparent;
}
::-webkit-scrollbar-thumb {
background: oklch(0.7 0 0 / 25%);
background: color-mix(in oklch, var(--muted-foreground) 25%, transparent);
border-radius: 3px;
}
::-webkit-scrollbar-thumb:hover {
background: oklch(0.6 0 0 / 40%);
background: color-mix(in oklch, var(--muted-foreground) 40%, transparent);
}
.dark ::-webkit-scrollbar-thumb {
background: oklch(0.5 0 0 / 30%);
background: color-mix(in oklch, var(--muted-foreground) 30%, transparent);
}
.dark ::-webkit-scrollbar-thumb:hover {
background: oklch(0.5 0 0 / 50%);
background: color-mix(in oklch, var(--muted-foreground) 50%, transparent);
}
/* Prose (markdown) refinements */
@@ -179,16 +179,16 @@
margin-bottom: 0.25em;
}
.prose blockquote {
border-left-color: oklch(0.7 0.15 260 / 40%);
border-left-color: color-mix(in oklch, var(--primary) 40%, transparent);
font-style: normal;
}
.prose a {
color: oklch(0.55 0.2 250);
color: var(--primary);
text-decoration: underline;
text-underline-offset: 2px;
}
.dark .prose a {
color: oklch(0.7 0.18 250);
color: var(--primary);
}
.prose hr {
border-color: var(--border);
+18 -3
View File
@@ -2,8 +2,11 @@ import type { Metadata } from "next";
import { Geist, Geist_Mono } from "next/font/google";
import "./globals.css";
import { ThemeProvider } from "@/components/layout/ThemeProvider";
import { ThemeFamilyProvider } from "@/components/layout/ThemeFamilyProvider";
import { I18nProvider } from "@/components/layout/I18nProvider";
import { AppShell } from "@/components/layout/AppShell";
import { getAllThemeFamilies, getThemeFamilyMetas } from "@/lib/theme/loader";
import { renderThemeFamilyCSS } from "@/lib/theme/render-css";
const geistSans = Geist({
variable: "--font-geist-sans",
@@ -25,15 +28,27 @@ export default function RootLayout({
}: Readonly<{
children: React.ReactNode;
}>) {
const families = getAllThemeFamilies();
const familiesMeta = getThemeFamilyMetas();
const themeFamilyCSS = renderThemeFamilyCSS(families);
const validIds = families.map((f) => f.id);
return (
<html lang="en" suppressHydrationWarning>
<head>
{/* Anti-FOUC: set data-theme-family from localStorage, validate against known IDs */}
<script dangerouslySetInnerHTML={{ __html: `(function(){try{var v=${JSON.stringify(validIds)};var f=localStorage.getItem('codepilot_theme_family')||'default';if(v.indexOf(f)<0)f='default';document.documentElement.setAttribute('data-theme-family',f)}catch(e){}})();` }} />
<style id="theme-family-vars" dangerouslySetInnerHTML={{ __html: themeFamilyCSS }} />
</head>
<body
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
>
<ThemeProvider>
<I18nProvider>
<AppShell>{children}</AppShell>
</I18nProvider>
<ThemeFamilyProvider families={familiesMeta}>
<I18nProvider>
<AppShell>{children}</AppShell>
</I18nProvider>
</ThemeFamilyProvider>
</ThemeProvider>
</body>
</html>
+45 -23
View File
@@ -18,6 +18,8 @@ import {
SelectValue,
} from "@/components/ui/select";
import { cn } from "@/lib/utils";
import { useThemeFamily } from "@/lib/theme/context";
import { resolveShikiTheme, resolveShikiThemes, SHIKI_DEFAULT_LIGHT, SHIKI_DEFAULT_DARK } from "@/lib/theme/code-themes";
import { CheckIcon, CopyIcon } from "lucide-react";
import {
createContext,
@@ -121,7 +123,7 @@ const CodeBlockContext = createContext<CodeBlockContextType>({
code: "",
});
// Highlighter cache (singleton per language)
// Highlighter cache keyed by "lang:lightTheme:darkTheme"
const highlighterCache = new Map<
string,
Promise<HighlighterGeneric<BundledLanguage, BundledTheme>>
@@ -133,36 +135,45 @@ const tokensCache = new Map<string, TokenizedCode>();
// Subscribers for async token updates
const subscribers = new Map<string, Set<(result: TokenizedCode) => void>>();
const getTokensCacheKey = (code: string, language: BundledLanguage) => {
const getTokensCacheKey = (code: string, language: BundledLanguage, lightTheme: BundledTheme, darkTheme: BundledTheme) => {
const start = code.slice(0, 100);
const end = code.length > 100 ? code.slice(-100) : "";
return `${language}:${code.length}:${start}:${end}`;
return `${language}:${lightTheme}:${darkTheme}:${code.length}:${start}:${end}`;
};
const isBundledLanguage = (lang: string): lang is BundledLanguage =>
lang in bundledLanguages || lang === "text" || lang === "plaintext";
const getHighlighter = (
language: BundledLanguage
language: BundledLanguage,
lightTheme: BundledTheme = SHIKI_DEFAULT_LIGHT,
darkTheme: BundledTheme = SHIKI_DEFAULT_DARK,
): Promise<HighlighterGeneric<BundledLanguage, BundledTheme>> => {
// Normalize unknown languages to "text" before hitting Shiki
const safeLang = isBundledLanguage(language) ? language : ("text" as BundledLanguage);
const cacheKey = `${safeLang}:${lightTheme}:${darkTheme}`;
const cached = highlighterCache.get(safeLang);
const cached = highlighterCache.get(cacheKey);
if (cached) {
return cached;
}
const highlighterPromise = createHighlighter({
langs: [safeLang],
themes: ["github-light", "github-dark"],
themes: [lightTheme, darkTheme],
}).catch(() => {
// Language not supported by Shiki — fall back to plain text
highlighterCache.delete(safeLang);
return getHighlighter("text" as BundledLanguage);
// Language or theme not supported — fall back to plain text + default themes.
// Using default themes avoids infinite retry if the *theme* was the problem.
highlighterCache.delete(cacheKey);
const useFallbackThemes =
lightTheme !== SHIKI_DEFAULT_LIGHT || darkTheme !== SHIKI_DEFAULT_DARK;
if (useFallbackThemes) {
return getHighlighter("text" as BundledLanguage, SHIKI_DEFAULT_LIGHT, SHIKI_DEFAULT_DARK);
}
return getHighlighter("text" as BundledLanguage, lightTheme, darkTheme);
});
highlighterCache.set(safeLang, highlighterPromise);
highlighterCache.set(cacheKey, highlighterPromise);
return highlighterPromise;
};
@@ -187,9 +198,11 @@ export const highlightCode = (
code: string,
language: BundledLanguage,
// oxlint-disable-next-line eslint-plugin-promise(prefer-await-to-callbacks)
callback?: (result: TokenizedCode) => void
callback?: (result: TokenizedCode) => void,
lightTheme: BundledTheme = SHIKI_DEFAULT_LIGHT,
darkTheme: BundledTheme = SHIKI_DEFAULT_DARK,
): TokenizedCode | null => {
const tokensCacheKey = getTokensCacheKey(code, language);
const tokensCacheKey = getTokensCacheKey(code, language, lightTheme, darkTheme);
// Return cached result if available
const cached = tokensCache.get(tokensCacheKey);
@@ -206,7 +219,7 @@ export const highlightCode = (
}
// Start highlighting in background - fire-and-forget async pattern
getHighlighter(language)
getHighlighter(language, lightTheme, darkTheme)
// oxlint-disable-next-line eslint-plugin-promise(prefer-await-to-then)
.then((highlighter) => {
const availableLangs = highlighter.getLoadedLanguages();
@@ -215,8 +228,8 @@ export const highlightCode = (
const result = highlighter.codeToTokens(code, {
lang: langToUse,
themes: {
dark: "github-dark",
light: "github-light",
dark: darkTheme,
light: lightTheme,
},
});
@@ -387,6 +400,13 @@ export const CodeBlockActions = ({
</div>
);
/** Resolve Shiki theme pair from the current theme family. */
function useShikiThemes(): { light: BundledTheme; dark: BundledTheme } {
const { family, families } = useThemeFamily();
const shikiTheme = resolveShikiTheme(families, family);
return resolveShikiThemes(shikiTheme);
}
export const CodeBlockContent = ({
code,
language,
@@ -396,18 +416,20 @@ export const CodeBlockContent = ({
language: BundledLanguage;
showLineNumbers?: boolean;
}) => {
const { light: lightTheme, dark: darkTheme } = useShikiThemes();
// Memoized raw tokens for immediate display
const rawTokens = useMemo(() => createRawTokens(code), [code]);
// Try to get cached result synchronously, otherwise use raw tokens
const syncTokenized = useMemo(
() => highlightCode(code, language) ?? rawTokens,
[code, language, rawTokens]
() => highlightCode(code, language, undefined, lightTheme, darkTheme) ?? rawTokens,
[code, language, rawTokens, lightTheme, darkTheme]
);
// Track async highlighting results keyed by code+language to avoid stale state
// Track async highlighting results keyed by code+language+themes to avoid stale state
const [asyncResult, setAsyncResult] = useState<{ key: string; tokens: TokenizedCode } | null>(null);
const resultKey = `${code}:${language}`;
const resultKey = `${code}:${language}:${lightTheme}:${darkTheme}`;
useEffect(() => {
let cancelled = false;
@@ -415,16 +437,16 @@ export const CodeBlockContent = ({
// Subscribe to async highlighting result
highlightCode(code, language, (result) => {
if (!cancelled) {
setAsyncResult({ key: `${code}:${language}`, tokens: result });
setAsyncResult({ key: `${code}:${language}:${lightTheme}:${darkTheme}`, tokens: result });
}
});
}, lightTheme, darkTheme);
return () => {
cancelled = true;
};
}, [code, language]);
}, [code, language, lightTheme, darkTheme]);
// Only use async result if it matches the current code+language
// Only use async result if it matches the current code+language+themes
const tokenized = (asyncResult && asyncResult.key === resultKey) ? asyncResult.tokens : syncTokenized;
return (
+3 -3
View File
@@ -257,7 +257,7 @@ export function BridgeSection() {
<div
className={`rounded-lg border p-4 transition-shadow hover:shadow-sm ${
isEnabled
? "border-blue-500/50 bg-blue-500/5"
? "border-primary/50 bg-primary/5"
: "border-border/50"
}`}
>
@@ -275,8 +275,8 @@ export function BridgeSection() {
/>
</div>
{isEnabled && (
<div className="mt-3 flex items-center gap-2 rounded-md bg-blue-500/10 px-3 py-2 text-xs text-blue-600 dark:text-blue-400">
<span className="h-2 w-2 shrink-0 rounded-full bg-blue-500" />
<div className="mt-3 flex items-center gap-2 rounded-md bg-primary/10 px-3 py-2 text-xs text-primary">
<span className="h-2 w-2 shrink-0 rounded-full bg-primary" />
{t("bridge.activeHint")}
</div>
)}
+48 -17
View File
@@ -2,7 +2,10 @@
import { useState, useMemo, useRef } from 'react';
import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter';
import { oneDark, vscDarkPlus } from 'react-syntax-highlighter/dist/esm/styles/prism';
import { vscDarkPlus } from 'react-syntax-highlighter/dist/esm/styles/prism';
import { useThemeFamily } from '@/lib/theme/context';
import { resolveCodeTheme, resolvePrismStyle } from '@/lib/theme/code-themes';
import { useTheme } from 'next-themes';
import { HugeiconsIcon } from "@hugeicons/react";
import type { IconSvgElement } from "@hugeicons/react";
import {
@@ -41,6 +44,14 @@ interface CodeBlockProps {
maxCollapsedLines?: number;
}
/** Resolve the Prism syntax highlighting style for CodeBlock. */
function useCodeBlockTheme(isTerminal: boolean, isDark: boolean) {
const { family, families } = useThemeFamily();
if (isTerminal) return vscDarkPlus;
const codeTheme = resolveCodeTheme(families, family);
return resolvePrismStyle(codeTheme, isDark);
}
export function CodeBlock({
code,
language = 'text',
@@ -53,6 +64,8 @@ export function CodeBlock({
const [expanded, setExpanded] = useState(false);
const codeContainerRef = useRef<HTMLDivElement>(null);
const [animatingHeight, setAnimatingHeight] = useState<string | undefined>(undefined);
const { resolvedTheme } = useTheme();
const isDark = resolvedTheme === 'dark';
const lines = useMemo(() => code.split('\n'), [code]);
const totalLines = lines.length;
@@ -93,17 +106,14 @@ export function CodeBlock({
}
const currentHeight = container.scrollHeight;
if (!expanded) {
// Expanding: set current height, then switch to auto after transition
setAnimatingHeight(`${currentHeight}px`);
setExpanded(true);
requestAnimationFrame(() => {
// Measure the full height after content change
const fullHeight = container.scrollHeight;
setAnimatingHeight(`${fullHeight}px`);
setTimeout(() => setAnimatingHeight(undefined), 300);
});
} else {
// Collapsing: set current height, then reduce
setAnimatingHeight(`${currentHeight}px`);
requestAnimationFrame(() => {
const collapsedH = maxCollapsedLines * 1.5 + 1.5;
@@ -118,35 +128,50 @@ export function CodeBlock({
const languageIconData = getLanguageIcon(language);
const theme = isTerminal ? vscDarkPlus : oneDark;
const theme = useCodeBlockTheme(isTerminal, isDark);
return (
<div className="relative group not-prose my-3 rounded-lg overflow-hidden border border-zinc-700/50">
<div className={cn(
"relative group not-prose my-3 rounded-lg overflow-hidden",
isTerminal
? "border border-zinc-700/50"
: "border border-border"
)}>
{/* Header bar */}
<div className={cn(
"flex items-center justify-between px-4 py-1.5 text-xs",
isTerminal
? "bg-zinc-950 text-zinc-400"
: "bg-zinc-800 dark:bg-zinc-900 text-zinc-400"
: "bg-muted text-muted-foreground"
)}>
<div className="flex items-center gap-2 min-w-0">
<HugeiconsIcon icon={languageIconData} className={cn(
"h-3.5 w-3.5 shrink-0",
isTerminal ? "text-green-400" : "text-zinc-400",
isTerminal ? "text-green-400" : "text-muted-foreground",
)} />
{filename && (
<span className="truncate text-zinc-300 font-medium">{filename}</span>
<span className={cn(
"truncate font-medium",
isTerminal ? "text-zinc-300" : "text-foreground"
)}>{filename}</span>
)}
{filename && <span className="text-zinc-600">|</span>}
{filename && <span className="text-muted-foreground/50">|</span>}
<span className={cn(
"bg-zinc-700/50 rounded px-1.5 py-0.5",
isTerminal && "text-green-400",
"rounded px-1.5 py-0.5",
isTerminal
? "bg-zinc-700/50 text-green-400"
: "bg-accent text-accent-foreground"
)}>{language.toUpperCase()}</span>
</div>
<div className="flex items-center gap-1 ml-2 shrink-0">
<button
onClick={handleCopy}
className="flex items-center gap-1 rounded px-1.5 py-0.5 text-zinc-400 hover:text-zinc-200 hover:bg-zinc-700/50 transition-colors"
className={cn(
"flex items-center gap-1 rounded px-1.5 py-0.5 transition-colors",
isTerminal
? "text-zinc-400 hover:text-zinc-200 hover:bg-zinc-700/50"
: "text-muted-foreground hover:text-foreground hover:bg-accent"
)}
title="Copy code"
>
{copied ? (
@@ -163,7 +188,12 @@ export function CodeBlock({
</button>
<button
onClick={handleCopyMarkdown}
className="flex items-center gap-1 rounded px-1.5 py-0.5 text-zinc-400 hover:text-zinc-200 hover:bg-zinc-700/50 transition-colors"
className={cn(
"flex items-center gap-1 rounded px-1.5 py-0.5 transition-colors",
isTerminal
? "text-zinc-400 hover:text-zinc-200 hover:bg-zinc-700/50"
: "text-muted-foreground hover:text-foreground hover:bg-accent"
)}
title="Copy as Markdown"
>
{copiedMarkdown ? (
@@ -197,7 +227,8 @@ export function CodeBlock({
lineNumberStyle={{
minWidth: '2.5em',
paddingRight: '1em',
color: '#3a3a48',
color: 'var(--muted-foreground)',
opacity: 0.4,
userSelect: 'none',
}}
customStyle={{
@@ -220,7 +251,7 @@ export function CodeBlock({
"absolute bottom-0 left-0 right-0 h-16 pointer-events-none",
isTerminal
? "bg-gradient-to-t from-[#0a0a0a] to-transparent"
: "bg-gradient-to-t from-[#282c34] to-transparent"
: "bg-gradient-to-t from-muted to-transparent"
)} />
)}
</div>
@@ -233,7 +264,7 @@ export function CodeBlock({
"flex w-full items-center justify-center gap-1.5 py-1.5 text-xs transition-colors",
isTerminal
? "bg-zinc-950 text-zinc-400 hover:text-zinc-200"
: "bg-zinc-800 dark:bg-zinc-900 text-zinc-400 hover:text-zinc-200"
: "bg-muted text-muted-foreground hover:text-foreground"
)}
>
{expanded ? (
+1 -1
View File
@@ -178,7 +178,7 @@ export function FolderPicker({ open, onOpenChange, onSelect, initialPath }: Fold
className="flex w-full items-center gap-2 rounded-md px-3 py-1.5 text-sm hover:bg-accent transition-colors text-left"
onClick={() => handleNavigate(dir.path)}
>
<HugeiconsIcon icon={Folder01Icon} className="h-4 w-4 shrink-0 text-blue-500" />
<HugeiconsIcon icon={Folder01Icon} className="h-4 w-4 shrink-0 text-primary" />
<span className="truncate">{dir.name}</span>
<HugeiconsIcon icon={ArrowRight01Icon} className="ml-auto h-3 w-3 shrink-0 text-muted-foreground" />
</button>
+2 -2
View File
@@ -287,7 +287,7 @@ export function ImageGenConfirmation({
'px-2.5 py-1 text-xs font-medium rounded-md border transition-colors',
'disabled:opacity-60 disabled:cursor-not-allowed',
aspectRatio === ratio
? 'border-blue-500 bg-blue-500/10 text-blue-600 dark:text-blue-400'
? 'border-primary bg-primary/10 text-primary'
: 'border-border/60 text-muted-foreground hover:text-foreground hover:border-foreground/30'
)}
>
@@ -313,7 +313,7 @@ export function ImageGenConfirmation({
'px-3 py-1 text-xs font-medium rounded-md border transition-colors',
'disabled:opacity-60 disabled:cursor-not-allowed',
resolution === res
? 'border-blue-500 bg-blue-500/10 text-blue-600 dark:text-blue-400'
? 'border-primary bg-primary/10 text-primary'
: 'border-border/60 text-muted-foreground hover:text-foreground hover:border-foreground/30'
)}
>
+1 -1
View File
@@ -27,7 +27,7 @@ export function ImageGenToggle() {
className={cn(
'inline-flex items-center justify-center rounded-full px-2.5 h-7 text-xs font-medium border transition-all',
state.enabled
? 'bg-blue-500/15 text-blue-600 dark:text-blue-400 border-blue-500/30'
? 'bg-primary/15 text-primary border-primary/30'
: 'text-muted-foreground border-border/60 hover:text-foreground hover:border-foreground/30 hover:bg-accent/50'
)}
>
+3 -3
View File
@@ -1133,15 +1133,15 @@ export function MessageInput({
{/* Command badge */}
{badge && (
<div className="flex w-full items-center gap-1.5 px-3 pt-2.5 pb-0 order-first">
<span className="inline-flex items-center gap-1.5 rounded-full bg-blue-500/10 text-blue-600 dark:text-blue-400 pl-2.5 pr-1.5 py-1 text-xs font-medium border border-blue-500/20">
<span className="inline-flex items-center gap-1.5 rounded-full bg-primary/10 text-primary pl-2.5 pr-1.5 py-1 text-xs font-medium border border-primary/20">
<span className="font-mono">{badge.command}</span>
{badge.description && (
<span className="text-blue-500/60 dark:text-blue-400/60 text-[10px]">{badge.description}</span>
<span className="text-primary/60 text-[10px]">{badge.description}</span>
)}
<button
type="button"
onClick={removeBadge}
className="ml-0.5 rounded-full p-0.5 hover:bg-blue-500/20 transition-colors"
className="ml-0.5 rounded-full p-0.5 hover:bg-primary/20 transition-colors"
>
<svg className="h-3 w-3" viewBox="0 0 12 12" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M3 3l6 6M9 3l-6 6" />
+1 -1
View File
@@ -106,7 +106,7 @@ function RewindButton({ sessionId, userMessageId }: { sessionId: string; userMes
</span>
<button
onClick={handleRewind}
className="text-[10px] text-blue-600 dark:text-blue-400 hover:underline"
className="text-[10px] text-primary hover:underline"
>
{t('messageList.rewindConfirm' as TranslationKey)}
</button>
+7 -7
View File
@@ -100,8 +100,8 @@ function StatusIndicator({ status }: { status: ToolStatus }) {
case 'running':
return (
<span className="relative flex h-3.5 w-3.5 items-center justify-center">
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-blue-400 opacity-30" />
<HugeiconsIcon icon={Loading02Icon} className="relative h-3.5 w-3.5 animate-spin text-blue-500" />
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-primary opacity-30" />
<HugeiconsIcon icon={Loading02Icon} className="relative h-3.5 w-3.5 animate-spin text-primary" />
</span>
);
case 'success':
@@ -282,13 +282,13 @@ export function ToolCallBlock({
};
const statusBorderColor = {
running: 'border-blue-500/70',
running: 'border-primary/70',
success: 'border-green-500/50',
error: 'border-red-500/60',
}[status];
const statusBgColor = {
running: 'bg-blue-500/[0.03] dark:bg-blue-500/[0.05]',
running: 'bg-primary/[0.03] dark:bg-primary/[0.05]',
success: 'bg-transparent',
error: 'bg-red-500/[0.03] dark:bg-red-500/[0.05]',
}[status];
@@ -309,11 +309,11 @@ export function ToolCallBlock({
)}
<HugeiconsIcon icon={toolIconData} className={cn(
"h-3.5 w-3.5 shrink-0",
category === 'read' && "text-blue-500",
category === 'read' && "text-primary",
category === 'write' && "text-amber-500",
category === 'bash' && "text-green-500",
category === 'search' && "text-indigo-500",
category === 'other' && "text-zinc-500",
category === 'search' && "text-primary",
category === 'other' && "text-muted-foreground",
)} />
<span className="font-mono text-xs truncate flex-1 text-foreground/80">{summary}</span>
<div className="flex items-center gap-2 shrink-0 ml-2">
@@ -13,7 +13,7 @@ export function BatchExecutionItem({ item }: BatchExecutionItemProps) {
const statusColor = {
pending: 'text-muted-foreground',
processing: 'text-blue-500',
processing: 'text-primary',
completed: 'text-green-500',
failed: 'text-red-500',
cancelled: 'text-muted-foreground',
@@ -37,7 +37,7 @@ export function BatchExecutionItem({ item }: BatchExecutionItemProps) {
{/* Status indicator */}
<div className="shrink-0">
{item.status === 'processing' ? (
<div className="w-4 h-4 rounded-full border-2 border-blue-500 border-t-transparent animate-spin" />
<div className="w-4 h-4 rounded-full border-2 border-primary border-t-transparent animate-spin" />
) : item.status === 'completed' ? (
<div className="w-4 h-4 rounded-full bg-green-500/20 flex items-center justify-center">
<svg className="w-2.5 h-2.5 text-green-500" viewBox="0 0 12 12" fill="none">
+13 -4
View File
@@ -6,8 +6,8 @@ import { HugeiconsIcon } from "@hugeicons/react";
import { Cancel01Icon, Copy01Icon, Tick01Icon, Loading02Icon } from "@hugeicons/core-free-icons";
import { Button } from "@/components/ui/button";
import { Light as SyntaxHighlighter } from "react-syntax-highlighter";
import { atomOneDark } from "react-syntax-highlighter/dist/esm/styles/hljs";
import { atomOneLight } from "react-syntax-highlighter/dist/esm/styles/hljs";
import { useThemeFamily } from "@/lib/theme/context";
import { resolveCodeTheme, resolveHljsStyle } from "@/lib/theme/code-themes";
import { Streamdown } from "streamdown";
import { cjk } from "@streamdown/cjk";
import { code } from "@streamdown/code";
@@ -216,13 +216,21 @@ function ViewModeToggle({
);
}
/** Resolve hljs style from the current theme family + mode. */
function useDocCodeTheme(isDark: boolean) {
const { family, families } = useThemeFamily();
const codeTheme = resolveCodeTheme(families, family);
return resolveHljsStyle(codeTheme, isDark);
}
/** Source code view using react-syntax-highlighter */
function SourceView({ preview, isDark }: { preview: FilePreviewType; isDark: boolean }) {
const hljsStyle = useDocCodeTheme(isDark);
return (
<div className="text-xs">
<SyntaxHighlighter
language={preview.language}
style={isDark ? atomOneDark : atomOneLight}
style={hljsStyle}
showLineNumbers
customStyle={{
margin: 0,
@@ -235,7 +243,8 @@ function SourceView({ preview, isDark }: { preview: FilePreviewType; isDark: boo
lineNumberStyle={{
minWidth: "2.5em",
paddingRight: "8px",
color: isDark ? "#636d83" : "#9ca3af",
color: "var(--muted-foreground)",
opacity: 0.5,
userSelect: "none",
}}
>
+6 -6
View File
@@ -70,7 +70,7 @@ function StepIcon({ status }: { status: string }) {
case "success":
return <HugeiconsIcon icon={Tick01Icon} className="size-4 text-emerald-500" />;
case "running":
return <HugeiconsIcon icon={Loading02Icon} className="size-4 text-blue-500 animate-spin" />;
return <HugeiconsIcon icon={Loading02Icon} className="size-4 text-primary animate-spin" />;
case "failed":
return <HugeiconsIcon icon={Cancel01Icon} className="size-4 text-red-500" />;
case "skipped":
@@ -223,11 +223,11 @@ export function InstallWizard({
useEffect(() => {
if (open) {
setPhase("checking"); // eslint-disable-line react-hooks/set-state-in-effect -- reset state before async check
setLogs([]); // eslint-disable-line react-hooks/set-state-in-effect
setProgress(null); // eslint-disable-line react-hooks/set-state-in-effect
setCopied(false); // eslint-disable-line react-hooks/set-state-in-effect
setCopiedBrew(false); // eslint-disable-line react-hooks/set-state-in-effect
setPrereqs(null); // eslint-disable-line react-hooks/set-state-in-effect
setLogs([]);
setProgress(null);
setCopied(false);
setCopiedBrew(false);
setPrereqs(null);
checkPrereqs();
}
return () => {
+24 -33
View File
@@ -2,8 +2,6 @@
import Link from "next/link";
import { usePathname, useRouter } from "next/navigation";
import { useTheme } from "next-themes";
import { useCallback, useSyncExternalStore } from "react";
import { HugeiconsIcon } from "@hugeicons/react";
import {
Message02Icon,
@@ -12,8 +10,6 @@ import {
Image01Icon,
Settings02Icon,
Wifi01Icon,
Moon02Icon,
Sun02Icon,
} from "@hugeicons/core-free-icons";
import { Button } from "@/components/ui/button";
import {
@@ -40,13 +36,11 @@ const navItems = [
{ href: "/mcp", label: "MCP", icon: Plug01Icon },
{ href: "/gallery", label: "Gallery", icon: Image01Icon },
{ href: "/bridge", label: "Bridge", icon: Wifi01Icon },
{ href: "/settings", label: "Settings", icon: Settings02Icon },
] as const;
export function NavRail({ onToggleChatList, hasUpdate, readyToInstall, skipPermissionsActive }: NavRailProps) {
const pathname = usePathname();
const router = useRouter();
const { theme, setTheme } = useTheme();
const { t } = useTranslation();
const navLabelKeys: Record<string, TranslationKey> = {
'Chats': 'nav.chats',
@@ -54,11 +48,9 @@ export function NavRail({ onToggleChatList, hasUpdate, readyToInstall, skipPermi
'MCP': 'extensions.mcpServers',
'Gallery': 'gallery.title',
'Bridge': 'nav.bridge',
'Settings': 'nav.settings',
};
const emptySubscribe = useCallback(() => () => {}, []);
const mounted = useSyncExternalStore(emptySubscribe, () => true, () => false);
const isChatRoute = pathname === "/chat" || pathname.startsWith("/chat/");
const isSettingsActive = pathname === "/settings" || pathname.startsWith("/settings/");
return (
<aside className="flex w-14 shrink-0 flex-col items-center bg-sidebar pb-3 pt-10">
@@ -110,12 +102,6 @@ export function NavRail({ onToggleChatList, hasUpdate, readyToInstall, skipPermi
<span className="sr-only">{t(navLabelKeys[item.label] ?? item.label as TranslationKey)}</span>
</Link>
</Button>
{item.href === "/settings" && hasUpdate && (
<span className={cn(
"absolute top-0.5 right-0.5 h-2 w-2 rounded-full",
readyToInstall ? "bg-green-500 animate-pulse" : "bg-blue-500"
)} />
)}
</div>
)}
</TooltipTrigger>
@@ -125,7 +111,7 @@ export function NavRail({ onToggleChatList, hasUpdate, readyToInstall, skipPermi
})}
</nav>
{/* Bottom: skip-permissions indicator + theme toggle */}
{/* Bottom: skip-permissions indicator + settings */}
<div className="mt-auto flex flex-col items-center gap-2">
{skipPermissionsActive && (
<Tooltip>
@@ -140,28 +126,33 @@ export function NavRail({ onToggleChatList, hasUpdate, readyToInstall, skipPermi
<TooltipContent side="right">{t('nav.autoApproveOn')}</TooltipContent>
</Tooltip>
)}
{mounted && (
<Tooltip>
<TooltipTrigger asChild>
<Tooltip>
<TooltipTrigger asChild>
<div className="relative">
<Button
asChild
variant="ghost"
size="icon"
onClick={() => setTheme(theme === "dark" ? "light" : "dark")}
className="h-8 w-8"
>
{theme === "dark" ? (
<HugeiconsIcon icon={Sun02Icon} className="h-4 w-4" />
) : (
<HugeiconsIcon icon={Moon02Icon} className="h-4 w-4" />
className={cn(
"h-9 w-9",
isSettingsActive && "bg-sidebar-accent text-sidebar-accent-foreground"
)}
<span className="sr-only">{t('nav.toggleTheme')}</span>
>
<Link href="/settings">
<HugeiconsIcon icon={Settings02Icon} className="h-4 w-4" />
<span className="sr-only">{t('nav.settings')}</span>
</Link>
</Button>
</TooltipTrigger>
<TooltipContent side="right">
{theme === "dark" ? t('nav.lightMode') : t('nav.darkMode')}
</TooltipContent>
</Tooltip>
)}
{hasUpdate && (
<span className={cn(
"absolute top-0.5 right-0.5 h-2 w-2 rounded-full",
readyToInstall ? "bg-green-500 animate-pulse" : "bg-primary"
)} />
)}
</div>
</TooltipTrigger>
<TooltipContent side="right">{t('nav.settings')}</TooltipContent>
</Tooltip>
</div>
</aside>
);
+3 -3
View File
@@ -113,7 +113,7 @@ export function SplitColumn({ sessionId, isActive, onClose, onFocus }: SplitColu
<div
className={cn(
"flex flex-1 min-w-0 flex-col overflow-hidden rounded-md border-2 transition-colors",
isActive ? "border-blue-500" : "border-transparent"
isActive ? "border-primary" : "border-transparent"
)}
onClick={onFocus}
>
@@ -129,7 +129,7 @@ export function SplitColumn({ sessionId, isActive, onClose, onFocus }: SplitColu
<div
className={cn(
"flex flex-1 min-w-0 flex-col overflow-hidden rounded-md border-2 transition-colors",
isActive ? "border-blue-500" : "border-transparent"
isActive ? "border-primary" : "border-transparent"
)}
onClick={onFocus}
>
@@ -144,7 +144,7 @@ export function SplitColumn({ sessionId, isActive, onClose, onFocus }: SplitColu
<div
className={cn(
"flex flex-1 min-w-0 flex-col overflow-hidden rounded-md border-2 transition-colors",
isActive ? "border-blue-500" : "border-transparent"
isActive ? "border-primary" : "border-transparent"
)}
onClick={onFocus}
>
@@ -0,0 +1,60 @@
"use client";
import { useState, useCallback, useEffect, type ReactNode } from 'react';
import { ThemeFamilyContext } from '@/lib/theme/context';
import type { ThemeFamilyMeta } from '@/lib/theme/types';
const STORAGE_KEY = 'codepilot_theme_family';
function getInitialFamily(): string {
// Read from DOM attribute (set by anti-FOUC script before hydration)
if (typeof document !== 'undefined') {
return document.documentElement.dataset.themeFamily || 'default';
}
return 'default';
}
interface ThemeFamilyProviderProps {
families: ThemeFamilyMeta[];
children: ReactNode;
}
export function ThemeFamilyProvider({ families, children }: ThemeFamilyProviderProps) {
const [rawFamily, setFamilyState] = useState(getInitialFamily);
// Derive validated family: if stored ID no longer exists in loaded themes, use 'default'.
const isValid = families.length === 0 || families.some((f) => f.id === rawFamily);
const family = isValid ? rawFamily : 'default';
// When the derived family differs from raw (i.e. fallback occurred),
// sync DOM attribute and localStorage. This effect does NOT call setState,
// avoiding the cascading-render lint error.
useEffect(() => {
if (family !== rawFamily) {
document.documentElement.setAttribute('data-theme-family', family);
try {
localStorage.setItem(STORAGE_KEY, family);
} catch {
// localStorage may be unavailable
}
}
}, [family, rawFamily]);
const setFamily = useCallback((id: string) => {
setFamilyState(id);
// Sync to DOM immediately for instant visual update
document.documentElement.setAttribute('data-theme-family', id);
// Persist to localStorage (sole source of truth for theme family)
try {
localStorage.setItem(STORAGE_KEY, id);
} catch {
// localStorage may be unavailable
}
}, []);
return (
<ThemeFamilyContext.Provider value={{ family, setFamily, families }}>
{children}
</ThemeFamilyContext.Provider>
);
}
+1 -1
View File
@@ -11,7 +11,7 @@ export function UpdateBanner() {
if (!updateInfo?.isNativeUpdate || !updateInfo.readyToInstall) return null;
return (
<div className="flex items-center justify-center gap-3 border-b border-blue-500/20 bg-blue-500/10 px-4 py-1.5 text-sm">
<div className="flex items-center justify-center gap-3 border-b border-primary/20 bg-primary/10 px-4 py-1.5 text-sm">
<span>{t('update.readyToInstall', { version: updateInfo.latestVersion })}</span>
<Button size="sm" variant="outline" className="h-6 text-xs" onClick={quitAndInstall}>
{t('update.restartNow')}
+2 -2
View File
@@ -53,7 +53,7 @@ export function UpdateDialog() {
ol: ({ children }) => <ol className="mb-2 list-decimal pl-4 text-sm">{children}</ol>,
li: ({ children }) => <li className="mb-0.5">{children}</li>,
a: ({ href, children }) => (
<a href={href} target="_blank" rel="noopener noreferrer" className="text-blue-500 underline">
<a href={href} target="_blank" rel="noopener noreferrer" className="text-primary underline">
{children}
</a>
),
@@ -88,7 +88,7 @@ export function UpdateDialog() {
<div className="space-y-1">
<div className="h-2 w-full overflow-hidden rounded-full bg-muted">
<div
className="h-full rounded-full bg-blue-500 transition-all"
className="h-full rounded-full bg-primary transition-all"
style={{ width: `${Math.min(downloadProgress!, 100)}%` }}
/>
</div>
+1 -1
View File
@@ -277,7 +277,7 @@ export function McpManager() {
<span className={`h-2 w-2 rounded-full shrink-0 ${
s.status === 'connected' ? 'bg-green-500' :
s.status === 'failed' ? 'bg-red-500' :
s.status === 'pending' ? 'bg-blue-500' :
s.status === 'pending' ? 'bg-primary' :
s.status === 'disabled' ? 'bg-gray-400' :
'bg-yellow-500'
}`} />
+2 -2
View File
@@ -28,7 +28,7 @@ function getServerTypeInfo(server: MCPServer) {
const type = server.type || 'stdio';
switch (type) {
case 'sse':
return { label: 'SSE', icon: Wifi01Icon, color: 'text-blue-500' };
return { label: 'SSE', icon: Wifi01Icon, color: 'text-primary' };
case 'http':
return { label: 'HTTP', icon: GlobeIcon, color: 'text-green-500' };
default:
@@ -45,7 +45,7 @@ function getStatusBadge(status: McpRuntimeStatus['status']) {
case 'needs-auth':
return { label: 'Auth Required', className: 'bg-yellow-500/10 text-yellow-600 border-yellow-500/20' };
case 'pending':
return { label: 'Pending', className: 'bg-blue-500/10 text-blue-600 border-blue-500/20' };
return { label: 'Pending', className: 'bg-primary/10 text-primary border-primary/20' };
case 'disabled':
return { label: 'Disabled', className: 'bg-gray-500/10 text-gray-500 border-gray-500/20' };
default:
+15 -3
View File
@@ -6,9 +6,19 @@ import { ArrowLeft01Icon, Copy01Icon, Tick01Icon, Loading02Icon } from "@hugeico
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { ScrollArea } from "@/components/ui/scroll-area";
import { useTheme } from "next-themes";
import { Light as SyntaxHighlighter } from "react-syntax-highlighter";
import { atomOneDark } from "react-syntax-highlighter/dist/esm/styles/hljs";
import { useThemeFamily } from "@/lib/theme/context";
import { resolveCodeTheme, resolveHljsStyle } from "@/lib/theme/code-themes";
import { usePanel } from "@/hooks/usePanel";
function useFilePreviewCodeTheme() {
const { resolvedTheme } = useTheme();
const { family, families } = useThemeFamily();
const isDark = resolvedTheme === "dark";
const codeTheme = resolveCodeTheme(families, family);
return resolveHljsStyle(codeTheme, isDark);
}
import { useTranslation } from "@/hooks/useTranslation";
import type { FilePreview as FilePreviewType } from "@/types";
@@ -20,6 +30,7 @@ interface FilePreviewProps {
export function FilePreview({ filePath, onBack }: FilePreviewProps) {
const { workingDirectory } = usePanel();
const { t } = useTranslation();
const hljsStyle = useFilePreviewCodeTheme();
const [preview, setPreview] = useState<FilePreviewType | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
@@ -119,7 +130,7 @@ export function FilePreview({ filePath, onBack }: FilePreviewProps) {
<div className="rounded-md border border-border text-xs">
<SyntaxHighlighter
language={preview.language}
style={atomOneDark}
style={hljsStyle}
showLineNumbers
customStyle={{
margin: 0,
@@ -131,7 +142,8 @@ export function FilePreview({ filePath, onBack }: FilePreviewProps) {
lineNumberStyle={{
minWidth: "2.5em",
paddingRight: "8px",
color: "#636d83",
color: "var(--muted-foreground)",
opacity: 0.5,
userSelect: "none",
}}
>
+1 -1
View File
@@ -15,7 +15,7 @@ interface TaskCardProps {
const statusColors: Record<TaskStatus, string> = {
pending: "bg-yellow-500",
in_progress: "bg-blue-500",
in_progress: "bg-primary",
completed: "bg-green-500",
failed: "bg-red-500",
};
@@ -0,0 +1,226 @@
"use client";
import { useEffect, useState, useSyncExternalStore } from "react";
import { useTheme } from "next-themes";
import { codeToHtml, type BundledTheme } from "shiki";
import { useThemeFamily } from "@/lib/theme/context";
import {
resolveShikiTheme,
resolveShikiThemes,
} from "@/lib/theme/code-themes";
import { useTranslation } from "@/hooks/useTranslation";
import { HugeiconsIcon } from "@hugeicons/react";
import { Sun02Icon, Moon02Icon, ComputerDesk01Icon } from "@hugeicons/core-free-icons";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { cn } from "@/lib/utils";
// ── Theme Mode Pill Selector ────────────────────────────────────────
const MODE_OPTIONS = [
{ value: "light", icon: Sun02Icon, labelKey: "settings.modeLight" as const },
{ value: "dark", icon: Moon02Icon, labelKey: "settings.modeDark" as const },
{ value: "system", icon: ComputerDesk01Icon, labelKey: "settings.modeSystem" as const },
] as const;
function ThemeModePills({
value,
onChange,
}: {
value: string;
onChange: (v: string) => void;
}) {
const { t } = useTranslation();
return (
<div className="flex items-center rounded-lg border border-border/50 p-1 gap-1" role="radiogroup">
{MODE_OPTIONS.map((opt) => {
const selected = value === opt.value;
return (
<button
key={opt.value}
role="radio"
aria-checked={selected}
onClick={() => onChange(opt.value)}
className={cn(
"flex items-center gap-1.5 rounded-md px-3 py-1.5 text-xs font-medium transition-all",
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
selected
? "bg-primary text-primary-foreground shadow-sm"
: "text-muted-foreground hover:bg-accent hover:text-accent-foreground"
)}
>
<HugeiconsIcon icon={opt.icon} className="h-3.5 w-3.5" />
{t(opt.labelKey)}
</button>
);
})}
</div>
);
}
// ── Shiki Code Preview ──────────────────────────────────────────────
const PREVIEW_CODE = `function greet(name: string) {
const time = new Date().getHours();
if (time < 12) return \`Good morning, \${name}\`;
return \`Hello, \${name}\`;
}`;
function ShikiCodePreview({ isDark }: { isDark: boolean }) {
const { family, families } = useThemeFamily();
const shikiMapping = resolveShikiTheme(families, family);
const { light, dark } = resolveShikiThemes(shikiMapping);
const theme: BundledTheme = isDark ? dark : light;
const [html, setHtml] = useState("");
useEffect(() => {
let cancelled = false;
codeToHtml(PREVIEW_CODE, {
lang: "typescript",
theme,
}).then((result) => {
if (!cancelled) setHtml(result);
}).catch(() => {});
return () => { cancelled = true; };
}, [theme]);
return (
<div className="rounded-md border border-border overflow-hidden">
<div className="flex items-center justify-between px-3 py-1.5 text-xs bg-muted text-muted-foreground">
<span className="font-medium">preview.ts</span>
<span className="rounded bg-accent px-1.5 py-0.5 text-accent-foreground">TypeScript</span>
</div>
{html ? (
<div
className="shiki-preview [&_pre]:!m-0 [&_pre]:!rounded-none [&_pre]:!text-xs [&_pre]:!leading-relaxed [&_pre]:!p-2 [&_code]:!text-xs"
dangerouslySetInnerHTML={{ __html: html }}
/>
) : (
<div className="h-24 flex items-center justify-center text-xs text-muted-foreground">
Loading…
</div>
)}
</div>
);
}
// ── UI Token Preview ────────────────────────────────────────────────
function UIPreview() {
return (
<div className="flex flex-wrap gap-2">
<button className="rounded-md bg-primary px-3 py-1 text-xs font-medium text-primary-foreground">
Primary
</button>
<button className="rounded-md bg-secondary px-3 py-1 text-xs font-medium text-secondary-foreground">
Secondary
</button>
<button className="rounded-md bg-destructive px-3 py-1 text-xs font-medium text-primary-foreground">
Destructive
</button>
<span className="inline-flex items-center rounded-full bg-accent px-2.5 py-0.5 text-[10px] font-medium text-accent-foreground">
Badge
</span>
<span className="inline-flex items-center rounded-full border border-border bg-card px-2.5 py-0.5 text-[10px] text-card-foreground">
Card
</span>
<span className="inline-flex items-center rounded-full bg-muted px-2.5 py-0.5 text-[10px] text-muted-foreground">
Muted
</span>
</div>
);
}
// ── Main Appearance Section ─────────────────────────────────────────
export function AppearanceSection() {
const { theme, setTheme, resolvedTheme } = useTheme();
const { family, setFamily, families } = useThemeFamily();
const { t } = useTranslation();
const isDark = resolvedTheme === "dark";
const mounted = useSyncExternalStore(
() => () => {},
() => true,
() => false
);
if (!mounted) return null;
return (
<div className="space-y-4">
{/* Section header — outside card */}
<div>
<h2 className="text-sm font-medium">{t("settings.appearance")}</h2>
<p className="text-xs text-muted-foreground">{t("settings.appearanceDesc")}</p>
</div>
<div className="rounded-lg border border-border/50 p-4 space-y-4 transition-shadow hover:shadow-sm">
{/* Mode */}
<div className="flex items-center justify-between gap-4">
<div className="min-w-0">
<h3 className="text-xs font-medium">{t("settings.themeMode")}</h3>
<p className="text-[11px] text-muted-foreground">{t("settings.themeModeDesc")}</p>
</div>
<ThemeModePills value={theme || "system"} onChange={setTheme} />
</div>
{theme === "system" && resolvedTheme && (
<p className="text-[11px] text-muted-foreground pl-1">
{resolvedTheme === "dark" ? t("settings.modeDark") : t("settings.modeLight")}
</p>
)}
<div className="border-t border-border/30" />
{/* Family */}
<div className="flex items-center justify-between gap-4">
<div className="min-w-0">
<h3 className="text-xs font-medium">{t("settings.themeFamily")}</h3>
<p className="text-[11px] text-muted-foreground">{t("settings.themeFamilyDesc")}</p>
</div>
<Select value={family} onValueChange={setFamily}>
<SelectTrigger className="w-[200px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
{families.map((f) => (
<SelectItem key={f.id} value={f.id}>
<span className="flex items-center gap-2">
{f.previewColors && (
<span className="flex gap-0.5">
<span
className="inline-block h-3 w-3 rounded-full border border-border/30"
style={{ background: f.previewColors.primaryLight }}
/>
<span
className="inline-block h-3 w-3 rounded-full border border-border/30"
style={{ background: f.previewColors.primaryDark }}
/>
</span>
)}
<span className="text-xs">{f.label}</span>
</span>
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="border-t border-border/30" />
{/* Preview */}
<div className="space-y-3">
<h3 className="text-xs font-medium text-muted-foreground">Preview</h3>
<UIPreview />
<ShikiCodePreview isDark={isDark} />
</div>
</div>
</div>
);
}
+16 -11
View File
@@ -20,6 +20,7 @@ import { useTranslation } from "@/hooks/useTranslation";
import { SUPPORTED_LOCALES, type Locale } from "@/i18n";
import type { TranslationKey } from "@/i18n";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { AppearanceSection } from "./AppearanceSection";
function UpdateCard() {
const { updateInfo, checking, checkForUpdates, downloadUpdate, quitAndInstall, setShowDialog } = useUpdate();
@@ -75,7 +76,7 @@ function UpdateCard() {
{updateInfo.updateAvailable ? (
<div className="space-y-2">
<div className="flex items-center gap-2">
<span className={`h-2 w-2 rounded-full ${updateInfo.readyToInstall ? 'bg-green-500' : isDownloading ? 'bg-yellow-500 animate-pulse' : 'bg-blue-500'}`} />
<span className={`h-2 w-2 rounded-full ${updateInfo.readyToInstall ? 'bg-green-500' : isDownloading ? 'bg-yellow-500 animate-pulse' : 'bg-primary'}`} />
<span className="text-sm">
{updateInfo.readyToInstall
? t('update.readyToInstall', { version: updateInfo.latestVersion })
@@ -98,7 +99,7 @@ function UpdateCard() {
{isDownloading && (
<div className="h-1.5 w-full overflow-hidden rounded-full bg-muted">
<div
className="h-full rounded-full bg-blue-500 transition-all"
className="h-full rounded-full bg-primary transition-all"
style={{ width: `${Math.min(updateInfo.downloadProgress!, 100)}%` }}
/>
</div>
@@ -209,8 +210,9 @@ export function GeneralSection() {
<div className="max-w-3xl space-y-6">
<UpdateCard />
{/* Auto-approve toggle */}
<div className={`rounded-lg border p-4 transition-shadow hover:shadow-sm ${skipPermissions ? "border-orange-500/50 bg-orange-500/5" : "border-border/50"}`}>
{/* General settings card */}
<div className={`rounded-lg border p-4 space-y-4 transition-shadow hover:shadow-sm ${skipPermissions ? "border-orange-500/50 bg-orange-500/5" : "border-border/50"}`}>
{/* Auto-approve toggle */}
<div className="flex items-center justify-between">
<div>
<h2 className="text-sm font-medium">{t('settings.autoApproveTitle')}</h2>
@@ -225,15 +227,15 @@ export function GeneralSection() {
/>
</div>
{skipPermissions && (
<div className="mt-3 flex items-center gap-2 rounded-md bg-orange-500/10 px-3 py-2 text-xs text-orange-600 dark:text-orange-400">
<div className="flex items-center gap-2 rounded-md bg-orange-500/10 px-3 py-2 text-xs text-orange-600 dark:text-orange-400">
<span className="h-2 w-2 shrink-0 rounded-full bg-orange-500" />
{t('settings.autoApproveWarning')}
</div>
)}
</div>
{/* Language picker */}
<div className="rounded-lg border border-border/50 p-4 transition-shadow hover:shadow-sm">
<div className="border-t border-border/30" />
{/* Language picker */}
<div className="flex items-center justify-between">
<div>
<h2 className="text-sm font-medium">{t('settings.language')}</h2>
@@ -250,10 +252,10 @@ export function GeneralSection() {
</SelectContent>
</Select>
</div>
</div>
{/* Thinking mode */}
<div className="rounded-lg border border-border/50 p-4 transition-shadow hover:shadow-sm">
<div className="border-t border-border/30" />
{/* Thinking mode */}
<div className="flex items-center justify-between">
<div>
<h2 className="text-sm font-medium">{t('settings.thinkingMode' as TranslationKey)}</h2>
@@ -272,6 +274,9 @@ export function GeneralSection() {
</div>
</div>
{/* Appearance */}
<AppearanceSection />
{/* Account info */}
{accountInfo && (
<div className="rounded-lg border border-border/50 p-4 transition-shadow hover:shadow-sm">
+1 -1
View File
@@ -134,7 +134,7 @@ export function CreateSkillDialog({
className={cn(
"flex-1 flex items-center gap-2 rounded-md border px-3 py-2 text-sm transition-colors",
scope === "project"
? "border-blue-500/50 bg-blue-500/10 text-blue-600 dark:text-blue-400"
? "border-primary/50 bg-primary/10 text-primary"
: "border-border hover:bg-accent"
)}
>
@@ -140,7 +140,7 @@ export function InstallProgressDialog({
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
{phase === "running" && (
<HugeiconsIcon icon={Loading02Icon} className="h-5 w-5 animate-spin text-blue-500" />
<HugeiconsIcon icon={Loading02Icon} className="h-5 w-5 animate-spin text-primary" />
)}
{phase === "success" && (
<HugeiconsIcon icon={CheckmarkCircle02Icon} className="h-5 w-5 text-green-500" />
+2 -2
View File
@@ -126,8 +126,8 @@ export function SkillEditor({ skill, onSave, onDelete }: SkillEditorProps) {
: skill.source === "installed"
? "border-orange-500/40 text-orange-600 dark:text-orange-400"
: skill.source === "plugin"
? "border-indigo-500/40 text-indigo-600 dark:text-indigo-400"
: "border-blue-500/40 text-blue-600 dark:text-blue-400"
? "border-primary/40 text-primary"
: "border-primary/40 text-primary"
)}
>
{skill.source === "global" ? (
+16
View File
@@ -0,0 +1,16 @@
import { useTheme } from 'next-themes';
import { useThemeFamily } from '@/lib/theme/context';
export function useAppTheme() {
const { theme, setTheme, resolvedTheme } = useTheme();
const { family, setFamily, families } = useThemeFamily();
return {
mode: theme,
setMode: setTheme,
resolvedMode: resolvedTheme,
family,
setFamily,
families,
};
}
+11
View File
@@ -85,6 +85,17 @@ const en = {
'settings.languageDesc': 'Choose the display language for the interface',
'settings.usage': 'Usage',
// ── Settings: Appearance ──────────────────────────────────────
'settings.appearance': 'Appearance',
'settings.appearanceDesc': 'Customize the look and feel of CodePilot',
'settings.themeMode': 'Theme Mode',
'settings.themeModeDesc': 'Choose between light, dark, or system preference',
'settings.themeFamily': 'Color Theme',
'settings.themeFamilyDesc': 'Select a color palette for the interface',
'settings.modeLight': 'Light',
'settings.modeDark': 'Dark',
'settings.modeSystem': 'System',
// ── Settings: Usage Stats ───────────────────────────────────
'usage.totalTokens': 'Total Tokens',
'usage.totalCost': 'Total Cost',
+11
View File
@@ -82,6 +82,17 @@ const zh: Record<TranslationKey, string> = {
'settings.languageDesc': '选择界面显示语言',
'settings.usage': '用量统计',
// ── Settings: Appearance ──────────────────────────────────────
'settings.appearance': '外观',
'settings.appearanceDesc': '自定义 CodePilot 的外观和风格',
'settings.themeMode': '主题模式',
'settings.themeModeDesc': '选择浅色、深色或跟随系统',
'settings.themeFamily': '颜色主题',
'settings.themeFamilyDesc': '为界面选择一套配色方案',
'settings.modeLight': '浅色',
'settings.modeDark': '深色',
'settings.modeSystem': '跟随系统',
// ── Settings: Usage Stats ───────────────────────────────────
'usage.totalTokens': '总 Token',
'usage.totalCost': '总费用',
+191
View File
@@ -0,0 +1,191 @@
/**
* Centralized code theme mappings for Prism, HLJS, and Shiki.
*
* Each theme family JSON can specify `codeTheme: { light, dark }` and
* `shikiTheme: { light, dark }` using the keys defined below.
* Components should use the hooks exported here rather than maintaining
* local maps.
*/
import type { CSSProperties } from 'react';
import type { BundledTheme } from 'shiki';
// ── Prism (react-syntax-highlighter/prism) ──────────────────────────────
import {
oneDark,
oneLight,
vscDarkPlus,
nord as nordPrism,
solarizedDarkAtom,
solarizedlight as solarizedLightPrism,
nightOwl as nightOwlPrism,
dracula as draculaPrism,
gruvboxDark as gruvboxDarkPrism,
gruvboxLight as gruvboxLightPrism,
ghcolors,
synthwave84 as synthwave84Prism,
materialDark as materialDarkPrism,
materialOceanic as materialOceanicPrism,
duotoneSea as duotoneSeaPrism,
coldarkDark as coldarkDarkPrism,
coldarkCold as coldarkColdPrism,
materialLight as materialLightPrism,
duotoneLight as duotoneLightPrism,
vs as vsPrism,
coy as coyPrism,
prism as prismDefault,
duotoneEarth as duotoneEarthPrism,
duotoneForest as duotoneForestPrism,
twilight as twilightPrism,
} from 'react-syntax-highlighter/dist/esm/styles/prism';
type SyntaxStyle = Record<string, CSSProperties>;
/** Prism theme name → style object. Keys match `codeTheme.dark` / `codeTheme.light` in theme JSONs. */
export const PRISM_THEME_MAP: Record<string, SyntaxStyle> = {
oneDark,
oneLight,
vscDarkPlus,
nord: nordPrism,
solarizedDarkAtom,
solarizedlight: solarizedLightPrism,
nightOwl: nightOwlPrism,
dracula: draculaPrism,
gruvboxDark: gruvboxDarkPrism,
gruvboxLight: gruvboxLightPrism,
ghcolors,
synthwave84: synthwave84Prism,
materialDark: materialDarkPrism,
materialOceanic: materialOceanicPrism,
duotoneSea: duotoneSeaPrism,
coldarkDark: coldarkDarkPrism,
coldarkCold: coldarkColdPrism,
materialLight: materialLightPrism,
duotoneLight: duotoneLightPrism,
vs: vsPrism,
coy: coyPrism,
prism: prismDefault,
duotoneEarth: duotoneEarthPrism,
duotoneForest: duotoneForestPrism,
twilight: twilightPrism,
};
export const PRISM_DEFAULT_DARK: SyntaxStyle = oneDark;
export const PRISM_DEFAULT_LIGHT: SyntaxStyle = oneLight;
// ── HLJS (react-syntax-highlighter/hljs) ────────────────────────────────
import {
atomOneDark,
atomOneLight,
nord as nordHljs,
solarizedDark,
solarizedLight,
nightOwl as nightOwlHljs,
dracula as draculaHljs,
gruvboxDark as gruvboxDarkHljs,
gruvboxLight as gruvboxLightHljs,
github as githubHljs,
vs2015 as vs2015Hljs,
monokaiSublime as monokaiSublimeHljs,
vs as vsHljs,
idea as ideaHljs,
lightfair as lightfairHljs,
xcode as xcodeHljs,
zenburn as zenburnHljs,
darcula as darculaHljs,
obsidian as obsidianHljs,
} from 'react-syntax-highlighter/dist/esm/styles/hljs';
type HljsStyle = Record<string, CSSProperties>;
/** HLJS theme name → style object. Same keys as Prism map where possible. */
export const HLJS_THEME_MAP: Record<string, HljsStyle> = {
oneDark: atomOneDark,
oneLight: atomOneLight,
nord: nordHljs,
solarizedDarkAtom: solarizedDark,
solarizedlight: solarizedLight,
nightOwl: nightOwlHljs,
dracula: draculaHljs,
gruvboxDark: gruvboxDarkHljs,
gruvboxLight: gruvboxLightHljs,
ghcolors: githubHljs,
synthwave84: monokaiSublimeHljs,
materialDark: vs2015Hljs,
materialOceanic: atomOneDark,
duotoneSea: atomOneDark,
coldarkDark: atomOneDark,
coldarkCold: atomOneLight,
materialLight: ideaHljs,
duotoneLight: atomOneLight,
vs: vsHljs,
coy: xcodeHljs,
prism: lightfairHljs,
duotoneEarth: darculaHljs,
duotoneForest: zenburnHljs,
twilight: obsidianHljs,
};
export const HLJS_DEFAULT_DARK: HljsStyle = atomOneDark;
export const HLJS_DEFAULT_LIGHT: HljsStyle = atomOneLight;
// ── Shiki ───────────────────────────────────────────────────────────────
export const SHIKI_DEFAULT_LIGHT: BundledTheme = 'github-light';
export const SHIKI_DEFAULT_DARK: BundledTheme = 'github-dark';
// ── Resolution helpers ──────────────────────────────────────────────────
import type { ThemeFamilyMeta, CodeThemeMapping } from './types';
/**
* Resolve a code-theme mapping from the current family metadata.
* Returns `undefined` if family has no `codeTheme`.
*/
export function resolveCodeTheme(
families: ThemeFamilyMeta[],
familyId: string,
): CodeThemeMapping | undefined {
return families.find((f) => f.id === familyId)?.codeTheme;
}
/**
* Resolve a shiki-theme mapping from the current family metadata.
* Returns `undefined` if family has no `shikiTheme`.
*/
export function resolveShikiTheme(
families: ThemeFamilyMeta[],
familyId: string,
): CodeThemeMapping | undefined {
return families.find((f) => f.id === familyId)?.shikiTheme;
}
/** Pick a Prism style for the given mode. Falls back to oneDark / oneLight. */
export function resolvePrismStyle(
codeTheme: CodeThemeMapping | undefined,
isDark: boolean,
): SyntaxStyle {
const name = isDark ? codeTheme?.dark : codeTheme?.light;
if (name && PRISM_THEME_MAP[name]) return PRISM_THEME_MAP[name];
return isDark ? PRISM_DEFAULT_DARK : PRISM_DEFAULT_LIGHT;
}
/** Pick an HLJS style for the given mode. Falls back to atomOneDark / atomOneLight. */
export function resolveHljsStyle(
codeTheme: CodeThemeMapping | undefined,
isDark: boolean,
): HljsStyle {
const name = isDark ? codeTheme?.dark : codeTheme?.light;
if (name && HLJS_THEME_MAP[name]) return HLJS_THEME_MAP[name];
return isDark ? HLJS_DEFAULT_DARK : HLJS_DEFAULT_LIGHT;
}
/** Pick a Shiki BundledTheme pair. Falls back to github-light / github-dark. */
export function resolveShikiThemes(
shikiTheme: CodeThemeMapping | undefined,
): { light: BundledTheme; dark: BundledTheme } {
return {
light: (shikiTheme?.light ?? SHIKI_DEFAULT_LIGHT) as BundledTheme,
dark: (shikiTheme?.dark ?? SHIKI_DEFAULT_DARK) as BundledTheme,
};
}
+18
View File
@@ -0,0 +1,18 @@
import { createContext, useContext } from 'react';
import type { ThemeFamilyMeta } from './types';
export interface ThemeFamilyContextValue {
family: string;
setFamily: (id: string) => void;
families: ThemeFamilyMeta[];
}
export const ThemeFamilyContext = createContext<ThemeFamilyContextValue>({
family: 'default',
setFamily: () => {},
families: [],
});
export function useThemeFamily() {
return useContext(ThemeFamilyContext);
}
+214
View File
@@ -0,0 +1,214 @@
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,
}));
}
+46
View File
@@ -0,0 +1,46 @@
import type { ThemeFamily, ThemeColors } from './types';
/** Convert camelCase key to CSS variable name: cardForeground → --card-foreground */
export function colorKeyToCssVar(key: string): string {
// Insert hyphens before uppercase letters, then lowercase
// Special handling for numbered keys like chart1 → --chart-1
const kebab = key
.replace(/([A-Z])/g, '-$1')
.replace(/(\d+)/g, '-$1')
.toLowerCase();
return `--${kebab}`;
}
function renderColorBlock(colors: ThemeColors): string {
const lines: string[] = [];
for (const [key, value] of Object.entries(colors)) {
lines.push(` ${colorKeyToCssVar(key)}: ${value};`);
}
return lines.join('\n');
}
/**
* Generate CSS for all theme families.
*
* Specificity:
* html[data-theme-family="x"] → (0,1,0) — beats :root (0,0,1)
* html.dark[data-theme-family="x"] → (0,2,0) — beats .dark (0,1,0)
*
* The globals.css :root/.dark blocks remain as fallback when data-theme-family is absent.
*/
export function renderThemeFamilyCSS(families: ThemeFamily[]): string {
const blocks: string[] = [];
for (const family of families) {
// Light mode
blocks.push(
`html[data-theme-family="${family.id}"] {\n${renderColorBlock(family.light)}\n}`
);
// Dark mode
blocks.push(
`html.dark[data-theme-family="${family.id}"] {\n${renderColorBlock(family.dark)}\n}`
);
}
return blocks.join('\n\n');
}
+78
View File
@@ -0,0 +1,78 @@
/**
* Theme Family system types.
*
* A "theme family" controls the color palette independently of light/dark mode.
* Mode is handled by next-themes; family is a second layer via CSS variables.
*/
/** All CSS variable keys matching globals.css :root block (camelCase). */
export interface ThemeColors {
background: string;
foreground: string;
card: string;
cardForeground: string;
popover: string;
popoverForeground: string;
primary: string;
primaryForeground: string;
secondary: string;
secondaryForeground: string;
muted: string;
mutedForeground: string;
accent: string;
accentForeground: string;
destructive: string;
border: string;
input: string;
ring: string;
chart1: string;
chart2: string;
chart3: string;
chart4: string;
chart5: string;
sidebar: string;
sidebarForeground: string;
sidebarPrimary: string;
sidebarPrimaryForeground: string;
sidebarAccent: string;
sidebarAccentForeground: string;
sidebarBorder: string;
sidebarRing: string;
}
/** Maps a code highlighting theme name for light and dark modes. */
export interface CodeThemeMapping {
light: string;
dark: string;
}
/** Full theme family definition (loaded from JSON). */
export interface ThemeFamily {
id: string;
label: string;
order: number;
description?: string;
light: ThemeColors;
dark: ThemeColors;
codeTheme?: CodeThemeMapping;
/** Shiki theme names (used by ai-elements code blocks). */
shikiTheme?: CodeThemeMapping;
}
/** Lightweight metadata for client-side use (selector UI). */
export interface ThemeFamilyMeta {
id: string;
label: string;
description?: string;
/** Preview colors for the selector (primary light, primary dark, accent light, background light). */
previewColors?: {
primaryLight: string;
primaryDark: string;
accentLight: string;
backgroundLight: string;
};
/** Code highlighting theme names (keys into react-syntax-highlighter theme maps). */
codeTheme?: CodeThemeMapping;
/** Shiki theme names for ai-elements code blocks. */
shikiTheme?: CodeThemeMapping;
}