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:
1 parent
7ad66f82c4
commit
4da188bfe5
53 files changed
+2446
-144
No files matched your search
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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
@@ -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
@@ -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>
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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 ? (
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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'
|
||||
)}
|
||||
>
|
||||
|
||||
@@ -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'
|
||||
)}
|
||||
>
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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",
|
||||
}}
|
||||
>
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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')}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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'
|
||||
}`} />
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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",
|
||||
}}
|
||||
>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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">
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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" ? (
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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',
|
||||
|
||||
@@ -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': '总费用',
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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,
|
||||
}));
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
Reference in new issue
Block a user