feat: theme family system with 12 color palettes + NavRail redesign

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

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

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

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
7418andClaude Opus 4.6 committed 2026-03-09 11:15:01 +08:00
1 parent 7ad66f82c4
commit 4da188bfe5
53 files changed
+2446 -144

No files matched your search

+1
View File
@@ -11,3 +11,4 @@
| agent-tooling-todo-bridge.md | SDK → SSE → DB 事件流、TodoWrite 字段映射、去重策略 |
| bridge-system.md | 多 IM 远程桥接系统架构(目录结构、数据流、设计决策) |
| assistant-workspace.md | 助理工作区:人格/记忆文件、对话式引导、自动触发、确定性落盘 |
| theme-system.md | 主题家族系统:两层架构、JSON schema、代码高亮三条渲染链、12 个主题清单 |
+82
View File
@@ -0,0 +1,82 @@
# Theme Family System / 主题家族系统
## 概述
两层主题系统:`mode`(light/dark/system,由 next-themes 管理)+ `theme family`(色板,由自定义 context + CSS 变量管理)。
## 架构
```
themes/*.json → 定义色板(OKLCH)、代码主题映射
src/lib/theme/loader.ts → 服务端读取 + 校验 JSON,按 order 排序
src/lib/theme/render-css.ts → 生成 html[data-theme-family="x"] 规则
src/app/layout.tsx → <head> 注入反闪烁脚本 + <style> 块
src/lib/theme/context.ts + ThemeFamilyProvider → 客户端状态 + localStorage
```
**CSS 特异性**:`html[data-theme-family]` (0,1,0) > `:root` (0,0,1);`html.dark[data-theme-family]` (0,2,0) > `.dark` (0,1,0)。globals.css 中的 `:root` / `.dark` 块作为兜底。
## 主题 JSON Schema
```jsonc
{
"id": "night-owl", // 唯一 ID,对应 data-theme-family 值
"label": "Night Owl", // 显示名
"order": 7, // 排序权重
"description": "...", // 可选短描述
"codeTheme": { // Prism / HLJS 代码高亮
"light": "coy", // 键名对应 code-themes.ts 中的 PRISM_THEME_MAP / HLJS_THEME_MAP
"dark": "nightOwl"
},
"shikiTheme": { // Shiki 代码高亮(ai-elements)
"light": "night-owl-light", // 值为 Shiki BundledTheme 名
"dark": "night-owl"
},
"light": { /* 31 个 ThemeColors 键,oklch 值 */ },
"dark": { /* 同上 */ }
}
```
## 代码高亮三条渲染链
| 消费组件 | 高亮器 | 解析函数 |
|---------|--------|---------|
| ChatCodeBlock (`CodeBlock.tsx`) | Prism | `resolvePrismStyle()` |
| DocPreview / FilePreview | HLJS | `resolveHljsStyle()` |
| AI Elements CodeBlock (`code-block.tsx`) | Shiki | `resolveShikiThemes()` |
| Settings Preview (`AppearanceSection.tsx`) | Shiki | `codeToHtml()` |
三条链路都通过 `useThemeFamily()` 取得当前 family,再查找对应的 `codeTheme` / `shikiTheme` 映射。
## 当前主题清单(12 个)
| ID | Order | Light Code | Dark Code | Shiki Light | Shiki Dark |
|----|-------|-----------|----------|------------|-----------|
| default | 0 | oneLight | oneDark | github-light | github-dark |
| github | 1 | ghcolors | vscDarkPlus | github-light-default | github-dark-default |
| rose-pine | 2 | duotoneLight | duotoneSea | rose-pine-dawn | rose-pine |
| tokyo-night | 3 | materialLight | materialOceanic | github-light | tokyo-night |
| everforest | 4 | gruvboxLight | duotoneForest | everforest-light | everforest-dark |
| nord | 5 | coldarkCold | nord | github-light | nord |
| kanagawa | 6 | vs | duotoneEarth | kanagawa-lotus | kanagawa-wave |
| night-owl | 7 | coy | nightOwl | night-owl-light | night-owl |
| poimandres | 8 | coldarkCold | coldarkDark | min-light | poimandres |
| vesper | 9 | gruvboxLight | twilight | min-light | vesper |
| horizon | 10 | duotoneLight | materialDark | github-light | horizon |
| synthwave84 | 11 | prism | synthwave84 | min-light | synthwave-84 |
## 新增主题
1. 在 `themes/` 目录创建 JSON 文件,遵循上述 schema
2. 填写所有 31 个 ThemeColors 键(light + dark),值用 OKLCH
3. `codeTheme` 键名必须在 `code-themes.ts` 的 `PRISM_THEME_MAP` / `HLJS_THEME_MAP` 中存在
4. `shikiTheme` 值必须是 Shiki `BundledTheme`
5. Loader 自动发现——无需改代码
## 关键设计决策
- **不用 Catppuccin / Solarized / Monokai 占位**:已删除早期占位主题,只保留有完整 light+dark 对且在两种模式下都有明显差异的 family
- **Rose Pine 合并**:Dawn 是 light 变体,Base 是 dark 变体,Moon 的 light 和 Base 一样 → 只保留一个 rose-pine family
- **Tokyo Night 合并**:Storm 和 Base 在 light 模式几乎无差异 → 只保留一个 tokyo-night family,light 使用真实 TN Light 色板
- **硬编码蓝色清理**:全局 `blue-*` / `indigo-*` Tailwind 类已替换为 `primary` 语义色,保留语义状态色(green/red/yellow)
- **Settings 预览用 Shiki**:与 ai-elements 实际渲染一致,而非 Prism 近似
+3
View File
@@ -36,6 +36,9 @@ extraResources:
- from: public/
to: standalone/public/
filter: ["**/*"]
- from: themes/
to: standalone/themes/
filter: ["*.json"]
- from: build/
to: .
filter: ["icon.*"]
+193
View File
@@ -0,0 +1,193 @@
/**
* Unit tests for theme family loader — tests the real public functions.
*
* Run with: npx tsx --test src/__tests__/unit/theme-loader.test.ts
*
* Strategy: stub process.cwd() to a temp dir and call _resetCache() before
* each test to get fresh loader results.
*/
import { describe, it, beforeEach, afterEach } from 'node:test';
import assert from 'node:assert/strict';
import fs from 'fs';
import path from 'path';
import os from 'os';
import { getAllThemeFamilies, getThemeFamilyMetas, _resetCache } from '../../lib/theme/loader';
/** Helper to create a minimal valid ThemeColors object. */
function makeColors(base: string) {
return {
background: base, foreground: base, card: base, cardForeground: base,
popover: base, popoverForeground: base, primary: base, primaryForeground: base,
secondary: base, secondaryForeground: base, muted: base, mutedForeground: base,
accent: base, accentForeground: base, destructive: base, border: base,
input: base, ring: base, chart1: base, chart2: base, chart3: base,
chart4: base, chart5: base, sidebar: base, sidebarForeground: base,
sidebarPrimary: base, sidebarPrimaryForeground: base, sidebarAccent: base,
sidebarAccentForeground: base, sidebarBorder: base, sidebarRing: base,
};
}
function makeTheme(id: string, order: number, color = 'oklch(0.5 0 0)') {
return { id, label: id, order, light: makeColors(color), dark: makeColors(color) };
}
let tmpDir: string;
const origCwd = process.cwd;
describe('theme-loader: getAllThemeFamilies', () => {
beforeEach(() => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'theme-loader-test-'));
fs.mkdirSync(path.join(tmpDir, 'themes'));
_resetCache();
process.cwd = () => tmpDir;
});
afterEach(() => {
process.cwd = origCwd;
_resetCache();
fs.rmSync(tmpDir, { recursive: true, force: true });
});
it('loads valid theme files and sorts by order', () => {
fs.writeFileSync(path.join(tmpDir, 'themes', 'b.json'), JSON.stringify(makeTheme('beta', 2)));
fs.writeFileSync(path.join(tmpDir, 'themes', 'a.json'), JSON.stringify(makeTheme('alpha', 1)));
fs.writeFileSync(path.join(tmpDir, 'themes', 'default.json'), JSON.stringify(makeTheme('default', 0)));
const families = getAllThemeFamilies();
assert.equal(families.length, 3);
assert.equal(families[0].id, 'default');
assert.equal(families[1].id, 'alpha');
assert.equal(families[2].id, 'beta');
});
it('skips invalid JSON files and still loads valid ones', () => {
fs.writeFileSync(path.join(tmpDir, 'themes', 'good.json'), JSON.stringify(makeTheme('good', 1)));
fs.writeFileSync(path.join(tmpDir, 'themes', 'bad.json'), 'not valid json {{{');
fs.writeFileSync(path.join(tmpDir, 'themes', 'incomplete.json'), JSON.stringify({ id: 'incomplete', label: 'X' }));
const families = getAllThemeFamilies();
const ids = families.map(f => f.id);
assert.ok(ids.includes('good'), 'valid theme loaded');
assert.ok(ids.includes('default'), 'default synthesized');
assert.ok(!ids.includes('incomplete'), 'incomplete theme skipped');
});
it('synthesizes default when no default.json exists', () => {
fs.writeFileSync(path.join(tmpDir, 'themes', 'custom.json'), JSON.stringify(makeTheme('custom', 5)));
const families = getAllThemeFamilies();
assert.ok(families.some(f => f.id === 'default'), 'default family exists');
assert.equal(families[0].id, 'default', 'default sorts first');
assert.equal(families[0].light.primary, 'oklch(0.546 0.245 262.881)', 'uses hardcoded fallback');
});
it('handles missing themes directory gracefully', () => {
fs.rmSync(path.join(tmpDir, 'themes'), { recursive: true });
const families = getAllThemeFamilies();
assert.equal(families.length, 1);
assert.equal(families[0].id, 'default');
});
it('ignores non-JSON files', () => {
fs.writeFileSync(path.join(tmpDir, 'themes', 'readme.md'), '# Themes');
fs.writeFileSync(path.join(tmpDir, 'themes', 'valid.json'), JSON.stringify(makeTheme('valid', 1)));
const families = getAllThemeFamilies();
assert.equal(families.length, 2); // valid + synthesized default
});
it('rejects themes missing chart/sidebar color keys', () => {
// Create a theme with only the old required keys (no chart*, no sidebar*)
const partialColors = {
background: 'x', foreground: 'x', card: 'x', cardForeground: 'x',
popover: 'x', popoverForeground: 'x', primary: 'x', primaryForeground: 'x',
secondary: 'x', secondaryForeground: 'x', muted: 'x', mutedForeground: 'x',
accent: 'x', accentForeground: 'x', destructive: 'x', border: 'x',
input: 'x', ring: 'x',
};
const partial = { id: 'partial', label: 'Partial', order: 1, light: partialColors, dark: partialColors };
fs.writeFileSync(path.join(tmpDir, 'themes', 'partial.json'), JSON.stringify(partial));
const families = getAllThemeFamilies();
assert.ok(!families.some(f => f.id === 'partial'), 'theme missing chart/sidebar keys is rejected');
});
it('strips invalid codeTheme/shikiTheme but keeps the theme', () => {
const theme = {
...makeTheme('fixable', 1),
codeTheme: { light: 123, dark: 'oneDark' }, // light is wrong type
shikiTheme: 'not-an-object', // completely wrong
};
fs.writeFileSync(path.join(tmpDir, 'themes', 'fixable.json'), JSON.stringify(theme));
const families = getAllThemeFamilies();
const loaded = families.find(f => f.id === 'fixable');
assert.ok(loaded, 'theme itself is still loaded');
assert.equal(loaded!.codeTheme, undefined, 'invalid codeTheme stripped');
assert.equal(loaded!.shikiTheme, undefined, 'invalid shikiTheme stripped');
});
it('caches results across calls', () => {
fs.writeFileSync(path.join(tmpDir, 'themes', 'default.json'), JSON.stringify(makeTheme('default', 0)));
const first = getAllThemeFamilies();
// Add another file after first load
fs.writeFileSync(path.join(tmpDir, 'themes', 'extra.json'), JSON.stringify(makeTheme('extra', 1)));
const second = getAllThemeFamilies();
assert.strictEqual(first, second, 'returns same reference from cache');
assert.equal(second.length, 1, 'does not pick up new files without cache reset');
});
});
describe('theme-loader: getThemeFamilyMetas', () => {
beforeEach(() => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'theme-meta-test-'));
fs.mkdirSync(path.join(tmpDir, 'themes'));
_resetCache();
process.cwd = () => tmpDir;
});
afterEach(() => {
process.cwd = origCwd;
_resetCache();
fs.rmSync(tmpDir, { recursive: true, force: true });
});
it('returns lightweight meta with preview colors and codeTheme', () => {
const theme = {
...makeTheme('test', 0, 'oklch(0.5 0.1 200)'),
description: 'A test theme',
codeTheme: { light: 'oneLight', dark: 'oneDark' },
shikiTheme: { light: 'one-light', dark: 'one-dark-pro' },
};
fs.writeFileSync(path.join(tmpDir, 'themes', 'test.json'), JSON.stringify(theme));
const metas = getThemeFamilyMetas();
const testMeta = metas.find(m => m.id === 'test');
assert.ok(testMeta, 'meta exists');
assert.equal(testMeta!.label, 'test');
assert.equal(testMeta!.description, 'A test theme');
assert.ok(testMeta!.previewColors, 'has preview colors');
assert.equal(testMeta!.previewColors!.primaryLight, 'oklch(0.5 0.1 200)');
assert.ok(testMeta!.codeTheme, 'has codeTheme');
assert.equal(testMeta!.codeTheme!.dark, 'oneDark');
assert.ok(testMeta!.shikiTheme, 'has shikiTheme');
assert.equal(testMeta!.shikiTheme!.dark, 'one-dark-pro');
});
it('meta does not include full color data', () => {
fs.writeFileSync(path.join(tmpDir, 'themes', 'default.json'), JSON.stringify(makeTheme('default', 0)));
const metas = getThemeFamilyMetas();
const meta = metas[0] as unknown as Record<string, unknown>;
assert.ok(!('light' in meta), 'no light colors in meta');
assert.ok(!('dark' in meta), 'no dark colors in meta');
assert.ok(!('order' in meta), 'no order in meta');
});
});
+107
View File
@@ -0,0 +1,107 @@
/**
* Unit tests for theme family CSS renderer.
*
* Run with: npx tsx --test src/__tests__/unit/theme-render-css.test.ts
*/
import { describe, it } from 'node:test';
import assert from 'node:assert/strict';
import { colorKeyToCssVar, renderThemeFamilyCSS } from '../../lib/theme/render-css';
import type { ThemeFamily } from '../../lib/theme/types';
describe('colorKeyToCssVar', () => {
it('should convert simple keys', () => {
assert.equal(colorKeyToCssVar('background'), '--background');
assert.equal(colorKeyToCssVar('foreground'), '--foreground');
assert.equal(colorKeyToCssVar('primary'), '--primary');
});
it('should convert camelCase to kebab-case', () => {
assert.equal(colorKeyToCssVar('cardForeground'), '--card-foreground');
assert.equal(colorKeyToCssVar('primaryForeground'), '--primary-foreground');
assert.equal(colorKeyToCssVar('mutedForeground'), '--muted-foreground');
assert.equal(colorKeyToCssVar('sidebarPrimaryForeground'), '--sidebar-primary-foreground');
});
it('should handle numbered keys', () => {
assert.equal(colorKeyToCssVar('chart1'), '--chart-1');
assert.equal(colorKeyToCssVar('chart5'), '--chart-5');
});
});
describe('renderThemeFamilyCSS', () => {
const makeColors = (val: string) => ({
background: val,
foreground: val,
card: val,
cardForeground: val,
popover: val,
popoverForeground: val,
primary: val,
primaryForeground: val,
secondary: val,
secondaryForeground: val,
muted: val,
mutedForeground: val,
accent: val,
accentForeground: val,
destructive: val,
border: val,
input: val,
ring: val,
chart1: val,
chart2: val,
chart3: val,
chart4: val,
chart5: val,
sidebar: val,
sidebarForeground: val,
sidebarPrimary: val,
sidebarPrimaryForeground: val,
sidebarAccent: val,
sidebarAccentForeground: val,
sidebarBorder: val,
sidebarRing: val,
});
const testFamily: ThemeFamily = {
id: 'test',
label: 'Test',
order: 0,
light: makeColors('oklch(1 0 0)'),
dark: makeColors('oklch(0.1 0 0)'),
};
it('should generate light and dark blocks', () => {
const css = renderThemeFamilyCSS([testFamily]);
assert.ok(css.includes('html[data-theme-family="test"]'));
assert.ok(css.includes('html.dark[data-theme-family="test"]'));
});
it('should include correct CSS variable assignments', () => {
const css = renderThemeFamilyCSS([testFamily]);
assert.ok(css.includes('--background: oklch(1 0 0);'));
assert.ok(css.includes('--background: oklch(0.1 0 0);'));
assert.ok(css.includes('--card-foreground:'));
assert.ok(css.includes('--sidebar-primary-foreground:'));
});
it('should handle multiple families', () => {
const family2: ThemeFamily = {
id: 'second',
label: 'Second',
order: 1,
light: makeColors('oklch(0.5 0 0)'),
dark: makeColors('oklch(0.2 0 0)'),
};
const css = renderThemeFamilyCSS([testFamily, family2]);
assert.ok(css.includes('html[data-theme-family="test"]'));
assert.ok(css.includes('html[data-theme-family="second"]'));
assert.ok(css.includes('html.dark[data-theme-family="second"]'));
});
it('should return empty string for empty input', () => {
const css = renderThemeFamilyCSS([]);
assert.equal(css, '');
});
});
+10 -10
View File
@@ -123,12 +123,12 @@
@apply bg-background text-foreground;
}
/* Selection color */
/* Selection color — derives from primary */
::selection {
background: oklch(0.7 0.15 260 / 30%);
background: color-mix(in oklch, var(--primary) 30%, transparent);
}
.dark ::selection {
background: oklch(0.6 0.2 260 / 35%);
background: color-mix(in oklch, var(--primary) 35%, transparent);
}
/* Custom scrollbar */
@@ -140,17 +140,17 @@
background: transparent;
}
::-webkit-scrollbar-thumb {
background: oklch(0.7 0 0 / 25%);
background: color-mix(in oklch, var(--muted-foreground) 25%, transparent);
border-radius: 3px;
}
::-webkit-scrollbar-thumb:hover {
background: oklch(0.6 0 0 / 40%);
background: color-mix(in oklch, var(--muted-foreground) 40%, transparent);
}
.dark ::-webkit-scrollbar-thumb {
background: oklch(0.5 0 0 / 30%);
background: color-mix(in oklch, var(--muted-foreground) 30%, transparent);
}
.dark ::-webkit-scrollbar-thumb:hover {
background: oklch(0.5 0 0 / 50%);
background: color-mix(in oklch, var(--muted-foreground) 50%, transparent);
}
/* Prose (markdown) refinements */
@@ -179,16 +179,16 @@
margin-bottom: 0.25em;
}
.prose blockquote {
border-left-color: oklch(0.7 0.15 260 / 40%);
border-left-color: color-mix(in oklch, var(--primary) 40%, transparent);
font-style: normal;
}
.prose a {
color: oklch(0.55 0.2 250);
color: var(--primary);
text-decoration: underline;
text-underline-offset: 2px;
}
.dark .prose a {
color: oklch(0.7 0.18 250);
color: var(--primary);
}
.prose hr {
border-color: var(--border);
+18 -3
View File
@@ -2,8 +2,11 @@ import type { Metadata } from "next";
import { Geist, Geist_Mono } from "next/font/google";
import "./globals.css";
import { ThemeProvider } from "@/components/layout/ThemeProvider";
import { ThemeFamilyProvider } from "@/components/layout/ThemeFamilyProvider";
import { I18nProvider } from "@/components/layout/I18nProvider";
import { AppShell } from "@/components/layout/AppShell";
import { getAllThemeFamilies, getThemeFamilyMetas } from "@/lib/theme/loader";
import { renderThemeFamilyCSS } from "@/lib/theme/render-css";
const geistSans = Geist({
variable: "--font-geist-sans",
@@ -25,15 +28,27 @@ export default function RootLayout({
}: Readonly<{
children: React.ReactNode;
}>) {
const families = getAllThemeFamilies();
const familiesMeta = getThemeFamilyMetas();
const themeFamilyCSS = renderThemeFamilyCSS(families);
const validIds = families.map((f) => f.id);
return (
<html lang="en" suppressHydrationWarning>
<head>
{/* Anti-FOUC: set data-theme-family from localStorage, validate against known IDs */}
<script dangerouslySetInnerHTML={{ __html: `(function(){try{var v=${JSON.stringify(validIds)};var f=localStorage.getItem('codepilot_theme_family')||'default';if(v.indexOf(f)<0)f='default';document.documentElement.setAttribute('data-theme-family',f)}catch(e){}})();` }} />
<style id="theme-family-vars" dangerouslySetInnerHTML={{ __html: themeFamilyCSS }} />
</head>
<body
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
>
<ThemeProvider>
<I18nProvider>
<AppShell>{children}</AppShell>
</I18nProvider>
<ThemeFamilyProvider families={familiesMeta}>
<I18nProvider>
<AppShell>{children}</AppShell>
</I18nProvider>
</ThemeFamilyProvider>
</ThemeProvider>
</body>
</html>
+45 -23
View File
@@ -18,6 +18,8 @@ import {
SelectValue,
} from "@/components/ui/select";
import { cn } from "@/lib/utils";
import { useThemeFamily } from "@/lib/theme/context";
import { resolveShikiTheme, resolveShikiThemes, SHIKI_DEFAULT_LIGHT, SHIKI_DEFAULT_DARK } from "@/lib/theme/code-themes";
import { CheckIcon, CopyIcon } from "lucide-react";
import {
createContext,
@@ -121,7 +123,7 @@ const CodeBlockContext = createContext<CodeBlockContextType>({
code: "",
});
// Highlighter cache (singleton per language)
// Highlighter cache keyed by "lang:lightTheme:darkTheme"
const highlighterCache = new Map<
string,
Promise<HighlighterGeneric<BundledLanguage, BundledTheme>>
@@ -133,36 +135,45 @@ const tokensCache = new Map<string, TokenizedCode>();
// Subscribers for async token updates
const subscribers = new Map<string, Set<(result: TokenizedCode) => void>>();
const getTokensCacheKey = (code: string, language: BundledLanguage) => {
const getTokensCacheKey = (code: string, language: BundledLanguage, lightTheme: BundledTheme, darkTheme: BundledTheme) => {
const start = code.slice(0, 100);
const end = code.length > 100 ? code.slice(-100) : "";
return `${language}:${code.length}:${start}:${end}`;
return `${language}:${lightTheme}:${darkTheme}:${code.length}:${start}:${end}`;
};
const isBundledLanguage = (lang: string): lang is BundledLanguage =>
lang in bundledLanguages || lang === "text" || lang === "plaintext";
const getHighlighter = (
language: BundledLanguage
language: BundledLanguage,
lightTheme: BundledTheme = SHIKI_DEFAULT_LIGHT,
darkTheme: BundledTheme = SHIKI_DEFAULT_DARK,
): Promise<HighlighterGeneric<BundledLanguage, BundledTheme>> => {
// Normalize unknown languages to "text" before hitting Shiki
const safeLang = isBundledLanguage(language) ? language : ("text" as BundledLanguage);
const cacheKey = `${safeLang}:${lightTheme}:${darkTheme}`;
const cached = highlighterCache.get(safeLang);
const cached = highlighterCache.get(cacheKey);
if (cached) {
return cached;
}
const highlighterPromise = createHighlighter({
langs: [safeLang],
themes: ["github-light", "github-dark"],
themes: [lightTheme, darkTheme],
}).catch(() => {
// Language not supported by Shiki — fall back to plain text
highlighterCache.delete(safeLang);
return getHighlighter("text" as BundledLanguage);
// Language or theme not supported — fall back to plain text + default themes.
// Using default themes avoids infinite retry if the *theme* was the problem.
highlighterCache.delete(cacheKey);
const useFallbackThemes =
lightTheme !== SHIKI_DEFAULT_LIGHT || darkTheme !== SHIKI_DEFAULT_DARK;
if (useFallbackThemes) {
return getHighlighter("text" as BundledLanguage, SHIKI_DEFAULT_LIGHT, SHIKI_DEFAULT_DARK);
}
return getHighlighter("text" as BundledLanguage, lightTheme, darkTheme);
});
highlighterCache.set(safeLang, highlighterPromise);
highlighterCache.set(cacheKey, highlighterPromise);
return highlighterPromise;
};
@@ -187,9 +198,11 @@ export const highlightCode = (
code: string,
language: BundledLanguage,
// oxlint-disable-next-line eslint-plugin-promise(prefer-await-to-callbacks)
callback?: (result: TokenizedCode) => void
callback?: (result: TokenizedCode) => void,
lightTheme: BundledTheme = SHIKI_DEFAULT_LIGHT,
darkTheme: BundledTheme = SHIKI_DEFAULT_DARK,
): TokenizedCode | null => {
const tokensCacheKey = getTokensCacheKey(code, language);
const tokensCacheKey = getTokensCacheKey(code, language, lightTheme, darkTheme);
// Return cached result if available
const cached = tokensCache.get(tokensCacheKey);
@@ -206,7 +219,7 @@ export const highlightCode = (
}
// Start highlighting in background - fire-and-forget async pattern
getHighlighter(language)
getHighlighter(language, lightTheme, darkTheme)
// oxlint-disable-next-line eslint-plugin-promise(prefer-await-to-then)
.then((highlighter) => {
const availableLangs = highlighter.getLoadedLanguages();
@@ -215,8 +228,8 @@ export const highlightCode = (
const result = highlighter.codeToTokens(code, {
lang: langToUse,
themes: {
dark: "github-dark",
light: "github-light",
dark: darkTheme,
light: lightTheme,
},
});
@@ -387,6 +400,13 @@ export const CodeBlockActions = ({
</div>
);
/** Resolve Shiki theme pair from the current theme family. */
function useShikiThemes(): { light: BundledTheme; dark: BundledTheme } {
const { family, families } = useThemeFamily();
const shikiTheme = resolveShikiTheme(families, family);
return resolveShikiThemes(shikiTheme);
}
export const CodeBlockContent = ({
code,
language,
@@ -396,18 +416,20 @@ export const CodeBlockContent = ({
language: BundledLanguage;
showLineNumbers?: boolean;
}) => {
const { light: lightTheme, dark: darkTheme } = useShikiThemes();
// Memoized raw tokens for immediate display
const rawTokens = useMemo(() => createRawTokens(code), [code]);
// Try to get cached result synchronously, otherwise use raw tokens
const syncTokenized = useMemo(
() => highlightCode(code, language) ?? rawTokens,
[code, language, rawTokens]
() => highlightCode(code, language, undefined, lightTheme, darkTheme) ?? rawTokens,
[code, language, rawTokens, lightTheme, darkTheme]
);
// Track async highlighting results keyed by code+language to avoid stale state
// Track async highlighting results keyed by code+language+themes to avoid stale state
const [asyncResult, setAsyncResult] = useState<{ key: string; tokens: TokenizedCode } | null>(null);
const resultKey = `${code}:${language}`;
const resultKey = `${code}:${language}:${lightTheme}:${darkTheme}`;
useEffect(() => {
let cancelled = false;
@@ -415,16 +437,16 @@ export const CodeBlockContent = ({
// Subscribe to async highlighting result
highlightCode(code, language, (result) => {
if (!cancelled) {
setAsyncResult({ key: `${code}:${language}`, tokens: result });
setAsyncResult({ key: `${code}:${language}:${lightTheme}:${darkTheme}`, tokens: result });
}
});
}, lightTheme, darkTheme);
return () => {
cancelled = true;
};
}, [code, language]);
}, [code, language, lightTheme, darkTheme]);
// Only use async result if it matches the current code+language
// Only use async result if it matches the current code+language+themes
const tokenized = (asyncResult && asyncResult.key === resultKey) ? asyncResult.tokens : syncTokenized;
return (
+3 -3
View File
@@ -257,7 +257,7 @@ export function BridgeSection() {
<div
className={`rounded-lg border p-4 transition-shadow hover:shadow-sm ${
isEnabled
? "border-blue-500/50 bg-blue-500/5"
? "border-primary/50 bg-primary/5"
: "border-border/50"
}`}
>
@@ -275,8 +275,8 @@ export function BridgeSection() {
/>
</div>
{isEnabled && (
<div className="mt-3 flex items-center gap-2 rounded-md bg-blue-500/10 px-3 py-2 text-xs text-blue-600 dark:text-blue-400">
<span className="h-2 w-2 shrink-0 rounded-full bg-blue-500" />
<div className="mt-3 flex items-center gap-2 rounded-md bg-primary/10 px-3 py-2 text-xs text-primary">
<span className="h-2 w-2 shrink-0 rounded-full bg-primary" />
{t("bridge.activeHint")}
</div>
)}
+48 -17
View File
@@ -2,7 +2,10 @@
import { useState, useMemo, useRef } from 'react';
import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter';
import { oneDark, vscDarkPlus } from 'react-syntax-highlighter/dist/esm/styles/prism';
import { vscDarkPlus } from 'react-syntax-highlighter/dist/esm/styles/prism';
import { useThemeFamily } from '@/lib/theme/context';
import { resolveCodeTheme, resolvePrismStyle } from '@/lib/theme/code-themes';
import { useTheme } from 'next-themes';
import { HugeiconsIcon } from "@hugeicons/react";
import type { IconSvgElement } from "@hugeicons/react";
import {
@@ -41,6 +44,14 @@ interface CodeBlockProps {
maxCollapsedLines?: number;
}
/** Resolve the Prism syntax highlighting style for CodeBlock. */
function useCodeBlockTheme(isTerminal: boolean, isDark: boolean) {
const { family, families } = useThemeFamily();
if (isTerminal) return vscDarkPlus;
const codeTheme = resolveCodeTheme(families, family);
return resolvePrismStyle(codeTheme, isDark);
}
export function CodeBlock({
code,
language = 'text',
@@ -53,6 +64,8 @@ export function CodeBlock({
const [expanded, setExpanded] = useState(false);
const codeContainerRef = useRef<HTMLDivElement>(null);
const [animatingHeight, setAnimatingHeight] = useState<string | undefined>(undefined);
const { resolvedTheme } = useTheme();
const isDark = resolvedTheme === 'dark';
const lines = useMemo(() => code.split('\n'), [code]);
const totalLines = lines.length;
@@ -93,17 +106,14 @@ export function CodeBlock({
}
const currentHeight = container.scrollHeight;
if (!expanded) {
// Expanding: set current height, then switch to auto after transition
setAnimatingHeight(`${currentHeight}px`);
setExpanded(true);
requestAnimationFrame(() => {
// Measure the full height after content change
const fullHeight = container.scrollHeight;
setAnimatingHeight(`${fullHeight}px`);
setTimeout(() => setAnimatingHeight(undefined), 300);
});
} else {
// Collapsing: set current height, then reduce
setAnimatingHeight(`${currentHeight}px`);
requestAnimationFrame(() => {
const collapsedH = maxCollapsedLines * 1.5 + 1.5;
@@ -118,35 +128,50 @@ export function CodeBlock({
const languageIconData = getLanguageIcon(language);
const theme = isTerminal ? vscDarkPlus : oneDark;
const theme = useCodeBlockTheme(isTerminal, isDark);
return (
<div className="relative group not-prose my-3 rounded-lg overflow-hidden border border-zinc-700/50">
<div className={cn(
"relative group not-prose my-3 rounded-lg overflow-hidden",
isTerminal
? "border border-zinc-700/50"
: "border border-border"
)}>
{/* Header bar */}
<div className={cn(
"flex items-center justify-between px-4 py-1.5 text-xs",
isTerminal
? "bg-zinc-950 text-zinc-400"
: "bg-zinc-800 dark:bg-zinc-900 text-zinc-400"
: "bg-muted text-muted-foreground"
)}>
<div className="flex items-center gap-2 min-w-0">
<HugeiconsIcon icon={languageIconData} className={cn(
"h-3.5 w-3.5 shrink-0",
isTerminal ? "text-green-400" : "text-zinc-400",
isTerminal ? "text-green-400" : "text-muted-foreground",
)} />
{filename && (
<span className="truncate text-zinc-300 font-medium">{filename}</span>
<span className={cn(
"truncate font-medium",
isTerminal ? "text-zinc-300" : "text-foreground"
)}>{filename}</span>
)}
{filename && <span className="text-zinc-600">|</span>}
{filename && <span className="text-muted-foreground/50">|</span>}
<span className={cn(
"bg-zinc-700/50 rounded px-1.5 py-0.5",
isTerminal && "text-green-400",
"rounded px-1.5 py-0.5",
isTerminal
? "bg-zinc-700/50 text-green-400"
: "bg-accent text-accent-foreground"
)}>{language.toUpperCase()}</span>
</div>
<div className="flex items-center gap-1 ml-2 shrink-0">
<button
onClick={handleCopy}
className="flex items-center gap-1 rounded px-1.5 py-0.5 text-zinc-400 hover:text-zinc-200 hover:bg-zinc-700/50 transition-colors"
className={cn(
"flex items-center gap-1 rounded px-1.5 py-0.5 transition-colors",
isTerminal
? "text-zinc-400 hover:text-zinc-200 hover:bg-zinc-700/50"
: "text-muted-foreground hover:text-foreground hover:bg-accent"
)}
title="Copy code"
>
{copied ? (
@@ -163,7 +188,12 @@ export function CodeBlock({
</button>
<button
onClick={handleCopyMarkdown}
className="flex items-center gap-1 rounded px-1.5 py-0.5 text-zinc-400 hover:text-zinc-200 hover:bg-zinc-700/50 transition-colors"
className={cn(
"flex items-center gap-1 rounded px-1.5 py-0.5 transition-colors",
isTerminal
? "text-zinc-400 hover:text-zinc-200 hover:bg-zinc-700/50"
: "text-muted-foreground hover:text-foreground hover:bg-accent"
)}
title="Copy as Markdown"
>
{copiedMarkdown ? (
@@ -197,7 +227,8 @@ export function CodeBlock({
lineNumberStyle={{
minWidth: '2.5em',
paddingRight: '1em',
color: '#3a3a48',
color: 'var(--muted-foreground)',
opacity: 0.4,
userSelect: 'none',
}}
customStyle={{
@@ -220,7 +251,7 @@ export function CodeBlock({
"absolute bottom-0 left-0 right-0 h-16 pointer-events-none",
isTerminal
? "bg-gradient-to-t from-[#0a0a0a] to-transparent"
: "bg-gradient-to-t from-[#282c34] to-transparent"
: "bg-gradient-to-t from-muted to-transparent"
)} />
)}
</div>
@@ -233,7 +264,7 @@ export function CodeBlock({
"flex w-full items-center justify-center gap-1.5 py-1.5 text-xs transition-colors",
isTerminal
? "bg-zinc-950 text-zinc-400 hover:text-zinc-200"
: "bg-zinc-800 dark:bg-zinc-900 text-zinc-400 hover:text-zinc-200"
: "bg-muted text-muted-foreground hover:text-foreground"
)}
>
{expanded ? (
+1 -1
View File
@@ -178,7 +178,7 @@ export function FolderPicker({ open, onOpenChange, onSelect, initialPath }: Fold
className="flex w-full items-center gap-2 rounded-md px-3 py-1.5 text-sm hover:bg-accent transition-colors text-left"
onClick={() => handleNavigate(dir.path)}
>
<HugeiconsIcon icon={Folder01Icon} className="h-4 w-4 shrink-0 text-blue-500" />
<HugeiconsIcon icon={Folder01Icon} className="h-4 w-4 shrink-0 text-primary" />
<span className="truncate">{dir.name}</span>
<HugeiconsIcon icon={ArrowRight01Icon} className="ml-auto h-3 w-3 shrink-0 text-muted-foreground" />
</button>
+2 -2
View File
@@ -287,7 +287,7 @@ export function ImageGenConfirmation({
'px-2.5 py-1 text-xs font-medium rounded-md border transition-colors',
'disabled:opacity-60 disabled:cursor-not-allowed',
aspectRatio === ratio
? 'border-blue-500 bg-blue-500/10 text-blue-600 dark:text-blue-400'
? 'border-primary bg-primary/10 text-primary'
: 'border-border/60 text-muted-foreground hover:text-foreground hover:border-foreground/30'
)}
>
@@ -313,7 +313,7 @@ export function ImageGenConfirmation({
'px-3 py-1 text-xs font-medium rounded-md border transition-colors',
'disabled:opacity-60 disabled:cursor-not-allowed',
resolution === res
? 'border-blue-500 bg-blue-500/10 text-blue-600 dark:text-blue-400'
? 'border-primary bg-primary/10 text-primary'
: 'border-border/60 text-muted-foreground hover:text-foreground hover:border-foreground/30'
)}
>
+1 -1
View File
@@ -27,7 +27,7 @@ export function ImageGenToggle() {
className={cn(
'inline-flex items-center justify-center rounded-full px-2.5 h-7 text-xs font-medium border transition-all',
state.enabled
? 'bg-blue-500/15 text-blue-600 dark:text-blue-400 border-blue-500/30'
? 'bg-primary/15 text-primary border-primary/30'
: 'text-muted-foreground border-border/60 hover:text-foreground hover:border-foreground/30 hover:bg-accent/50'
)}
>
+3 -3
View File
@@ -1133,15 +1133,15 @@ export function MessageInput({
{/* Command badge */}
{badge && (
<div className="flex w-full items-center gap-1.5 px-3 pt-2.5 pb-0 order-first">
<span className="inline-flex items-center gap-1.5 rounded-full bg-blue-500/10 text-blue-600 dark:text-blue-400 pl-2.5 pr-1.5 py-1 text-xs font-medium border border-blue-500/20">
<span className="inline-flex items-center gap-1.5 rounded-full bg-primary/10 text-primary pl-2.5 pr-1.5 py-1 text-xs font-medium border border-primary/20">
<span className="font-mono">{badge.command}</span>
{badge.description && (
<span className="text-blue-500/60 dark:text-blue-400/60 text-[10px]">{badge.description}</span>
<span className="text-primary/60 text-[10px]">{badge.description}</span>
)}
<button
type="button"
onClick={removeBadge}
className="ml-0.5 rounded-full p-0.5 hover:bg-blue-500/20 transition-colors"
className="ml-0.5 rounded-full p-0.5 hover:bg-primary/20 transition-colors"
>
<svg className="h-3 w-3" viewBox="0 0 12 12" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M3 3l6 6M9 3l-6 6" />
+1 -1
View File
@@ -106,7 +106,7 @@ function RewindButton({ sessionId, userMessageId }: { sessionId: string; userMes
</span>
<button
onClick={handleRewind}
className="text-[10px] text-blue-600 dark:text-blue-400 hover:underline"
className="text-[10px] text-primary hover:underline"
>
{t('messageList.rewindConfirm' as TranslationKey)}
</button>
+7 -7
View File
@@ -100,8 +100,8 @@ function StatusIndicator({ status }: { status: ToolStatus }) {
case 'running':
return (
<span className="relative flex h-3.5 w-3.5 items-center justify-center">
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-blue-400 opacity-30" />
<HugeiconsIcon icon={Loading02Icon} className="relative h-3.5 w-3.5 animate-spin text-blue-500" />
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-primary opacity-30" />
<HugeiconsIcon icon={Loading02Icon} className="relative h-3.5 w-3.5 animate-spin text-primary" />
</span>
);
case 'success':
@@ -282,13 +282,13 @@ export function ToolCallBlock({
};
const statusBorderColor = {
running: 'border-blue-500/70',
running: 'border-primary/70',
success: 'border-green-500/50',
error: 'border-red-500/60',
}[status];
const statusBgColor = {
running: 'bg-blue-500/[0.03] dark:bg-blue-500/[0.05]',
running: 'bg-primary/[0.03] dark:bg-primary/[0.05]',
success: 'bg-transparent',
error: 'bg-red-500/[0.03] dark:bg-red-500/[0.05]',
}[status];
@@ -309,11 +309,11 @@ export function ToolCallBlock({
)}
<HugeiconsIcon icon={toolIconData} className={cn(
"h-3.5 w-3.5 shrink-0",
category === 'read' && "text-blue-500",
category === 'read' && "text-primary",
category === 'write' && "text-amber-500",
category === 'bash' && "text-green-500",
category === 'search' && "text-indigo-500",
category === 'other' && "text-zinc-500",
category === 'search' && "text-primary",
category === 'other' && "text-muted-foreground",
)} />
<span className="font-mono text-xs truncate flex-1 text-foreground/80">{summary}</span>
<div className="flex items-center gap-2 shrink-0 ml-2">
@@ -13,7 +13,7 @@ export function BatchExecutionItem({ item }: BatchExecutionItemProps) {
const statusColor = {
pending: 'text-muted-foreground',
processing: 'text-blue-500',
processing: 'text-primary',
completed: 'text-green-500',
failed: 'text-red-500',
cancelled: 'text-muted-foreground',
@@ -37,7 +37,7 @@ export function BatchExecutionItem({ item }: BatchExecutionItemProps) {
{/* Status indicator */}
<div className="shrink-0">
{item.status === 'processing' ? (
<div className="w-4 h-4 rounded-full border-2 border-blue-500 border-t-transparent animate-spin" />
<div className="w-4 h-4 rounded-full border-2 border-primary border-t-transparent animate-spin" />
) : item.status === 'completed' ? (
<div className="w-4 h-4 rounded-full bg-green-500/20 flex items-center justify-center">
<svg className="w-2.5 h-2.5 text-green-500" viewBox="0 0 12 12" fill="none">
+13 -4
View File
@@ -6,8 +6,8 @@ import { HugeiconsIcon } from "@hugeicons/react";
import { Cancel01Icon, Copy01Icon, Tick01Icon, Loading02Icon } from "@hugeicons/core-free-icons";
import { Button } from "@/components/ui/button";
import { Light as SyntaxHighlighter } from "react-syntax-highlighter";
import { atomOneDark } from "react-syntax-highlighter/dist/esm/styles/hljs";
import { atomOneLight } from "react-syntax-highlighter/dist/esm/styles/hljs";
import { useThemeFamily } from "@/lib/theme/context";
import { resolveCodeTheme, resolveHljsStyle } from "@/lib/theme/code-themes";
import { Streamdown } from "streamdown";
import { cjk } from "@streamdown/cjk";
import { code } from "@streamdown/code";
@@ -216,13 +216,21 @@ function ViewModeToggle({
);
}
/** Resolve hljs style from the current theme family + mode. */
function useDocCodeTheme(isDark: boolean) {
const { family, families } = useThemeFamily();
const codeTheme = resolveCodeTheme(families, family);
return resolveHljsStyle(codeTheme, isDark);
}
/** Source code view using react-syntax-highlighter */
function SourceView({ preview, isDark }: { preview: FilePreviewType; isDark: boolean }) {
const hljsStyle = useDocCodeTheme(isDark);
return (
<div className="text-xs">
<SyntaxHighlighter
language={preview.language}
style={isDark ? atomOneDark : atomOneLight}
style={hljsStyle}
showLineNumbers
customStyle={{
margin: 0,
@@ -235,7 +243,8 @@ function SourceView({ preview, isDark }: { preview: FilePreviewType; isDark: boo
lineNumberStyle={{
minWidth: "2.5em",
paddingRight: "8px",
color: isDark ? "#636d83" : "#9ca3af",
color: "var(--muted-foreground)",
opacity: 0.5,
userSelect: "none",
}}
>
+6 -6
View File
@@ -70,7 +70,7 @@ function StepIcon({ status }: { status: string }) {
case "success":
return <HugeiconsIcon icon={Tick01Icon} className="size-4 text-emerald-500" />;
case "running":
return <HugeiconsIcon icon={Loading02Icon} className="size-4 text-blue-500 animate-spin" />;
return <HugeiconsIcon icon={Loading02Icon} className="size-4 text-primary animate-spin" />;
case "failed":
return <HugeiconsIcon icon={Cancel01Icon} className="size-4 text-red-500" />;
case "skipped":
@@ -223,11 +223,11 @@ export function InstallWizard({
useEffect(() => {
if (open) {
setPhase("checking"); // eslint-disable-line react-hooks/set-state-in-effect -- reset state before async check
setLogs([]); // eslint-disable-line react-hooks/set-state-in-effect
setProgress(null); // eslint-disable-line react-hooks/set-state-in-effect
setCopied(false); // eslint-disable-line react-hooks/set-state-in-effect
setCopiedBrew(false); // eslint-disable-line react-hooks/set-state-in-effect
setPrereqs(null); // eslint-disable-line react-hooks/set-state-in-effect
setLogs([]);
setProgress(null);
setCopied(false);
setCopiedBrew(false);
setPrereqs(null);
checkPrereqs();
}
return () => {
+24 -33
View File
@@ -2,8 +2,6 @@
import Link from "next/link";
import { usePathname, useRouter } from "next/navigation";
import { useTheme } from "next-themes";
import { useCallback, useSyncExternalStore } from "react";
import { HugeiconsIcon } from "@hugeicons/react";
import {
Message02Icon,
@@ -12,8 +10,6 @@ import {
Image01Icon,
Settings02Icon,
Wifi01Icon,
Moon02Icon,
Sun02Icon,
} from "@hugeicons/core-free-icons";
import { Button } from "@/components/ui/button";
import {
@@ -40,13 +36,11 @@ const navItems = [
{ href: "/mcp", label: "MCP", icon: Plug01Icon },
{ href: "/gallery", label: "Gallery", icon: Image01Icon },
{ href: "/bridge", label: "Bridge", icon: Wifi01Icon },
{ href: "/settings", label: "Settings", icon: Settings02Icon },
] as const;
export function NavRail({ onToggleChatList, hasUpdate, readyToInstall, skipPermissionsActive }: NavRailProps) {
const pathname = usePathname();
const router = useRouter();
const { theme, setTheme } = useTheme();
const { t } = useTranslation();
const navLabelKeys: Record<string, TranslationKey> = {
'Chats': 'nav.chats',
@@ -54,11 +48,9 @@ export function NavRail({ onToggleChatList, hasUpdate, readyToInstall, skipPermi
'MCP': 'extensions.mcpServers',
'Gallery': 'gallery.title',
'Bridge': 'nav.bridge',
'Settings': 'nav.settings',
};
const emptySubscribe = useCallback(() => () => {}, []);
const mounted = useSyncExternalStore(emptySubscribe, () => true, () => false);
const isChatRoute = pathname === "/chat" || pathname.startsWith("/chat/");
const isSettingsActive = pathname === "/settings" || pathname.startsWith("/settings/");
return (
<aside className="flex w-14 shrink-0 flex-col items-center bg-sidebar pb-3 pt-10">
@@ -110,12 +102,6 @@ export function NavRail({ onToggleChatList, hasUpdate, readyToInstall, skipPermi
<span className="sr-only">{t(navLabelKeys[item.label] ?? item.label as TranslationKey)}</span>
</Link>
</Button>
{item.href === "/settings" && hasUpdate && (
<span className={cn(
"absolute top-0.5 right-0.5 h-2 w-2 rounded-full",
readyToInstall ? "bg-green-500 animate-pulse" : "bg-blue-500"
)} />
)}
</div>
)}
</TooltipTrigger>
@@ -125,7 +111,7 @@ export function NavRail({ onToggleChatList, hasUpdate, readyToInstall, skipPermi
})}
</nav>
{/* Bottom: skip-permissions indicator + theme toggle */}
{/* Bottom: skip-permissions indicator + settings */}
<div className="mt-auto flex flex-col items-center gap-2">
{skipPermissionsActive && (
<Tooltip>
@@ -140,28 +126,33 @@ export function NavRail({ onToggleChatList, hasUpdate, readyToInstall, skipPermi
<TooltipContent side="right">{t('nav.autoApproveOn')}</TooltipContent>
</Tooltip>
)}
{mounted && (
<Tooltip>
<TooltipTrigger asChild>
<Tooltip>
<TooltipTrigger asChild>
<div className="relative">
<Button
asChild
variant="ghost"
size="icon"
onClick={() => setTheme(theme === "dark" ? "light" : "dark")}
className="h-8 w-8"
>
{theme === "dark" ? (
<HugeiconsIcon icon={Sun02Icon} className="h-4 w-4" />
) : (
<HugeiconsIcon icon={Moon02Icon} className="h-4 w-4" />
className={cn(
"h-9 w-9",
isSettingsActive && "bg-sidebar-accent text-sidebar-accent-foreground"
)}
<span className="sr-only">{t('nav.toggleTheme')}</span>
>
<Link href="/settings">
<HugeiconsIcon icon={Settings02Icon} className="h-4 w-4" />
<span className="sr-only">{t('nav.settings')}</span>
</Link>
</Button>
</TooltipTrigger>
<TooltipContent side="right">
{theme === "dark" ? t('nav.lightMode') : t('nav.darkMode')}
</TooltipContent>
</Tooltip>
)}
{hasUpdate && (
<span className={cn(
"absolute top-0.5 right-0.5 h-2 w-2 rounded-full",
readyToInstall ? "bg-green-500 animate-pulse" : "bg-primary"
)} />
)}
</div>
</TooltipTrigger>
<TooltipContent side="right">{t('nav.settings')}</TooltipContent>
</Tooltip>
</div>
</aside>
);
+3 -3
View File
@@ -113,7 +113,7 @@ export function SplitColumn({ sessionId, isActive, onClose, onFocus }: SplitColu
<div
className={cn(
"flex flex-1 min-w-0 flex-col overflow-hidden rounded-md border-2 transition-colors",
isActive ? "border-blue-500" : "border-transparent"
isActive ? "border-primary" : "border-transparent"
)}
onClick={onFocus}
>
@@ -129,7 +129,7 @@ export function SplitColumn({ sessionId, isActive, onClose, onFocus }: SplitColu
<div
className={cn(
"flex flex-1 min-w-0 flex-col overflow-hidden rounded-md border-2 transition-colors",
isActive ? "border-blue-500" : "border-transparent"
isActive ? "border-primary" : "border-transparent"
)}
onClick={onFocus}
>
@@ -144,7 +144,7 @@ export function SplitColumn({ sessionId, isActive, onClose, onFocus }: SplitColu
<div
className={cn(
"flex flex-1 min-w-0 flex-col overflow-hidden rounded-md border-2 transition-colors",
isActive ? "border-blue-500" : "border-transparent"
isActive ? "border-primary" : "border-transparent"
)}
onClick={onFocus}
>
@@ -0,0 +1,60 @@
"use client";
import { useState, useCallback, useEffect, type ReactNode } from 'react';
import { ThemeFamilyContext } from '@/lib/theme/context';
import type { ThemeFamilyMeta } from '@/lib/theme/types';
const STORAGE_KEY = 'codepilot_theme_family';
function getInitialFamily(): string {
// Read from DOM attribute (set by anti-FOUC script before hydration)
if (typeof document !== 'undefined') {
return document.documentElement.dataset.themeFamily || 'default';
}
return 'default';
}
interface ThemeFamilyProviderProps {
families: ThemeFamilyMeta[];
children: ReactNode;
}
export function ThemeFamilyProvider({ families, children }: ThemeFamilyProviderProps) {
const [rawFamily, setFamilyState] = useState(getInitialFamily);
// Derive validated family: if stored ID no longer exists in loaded themes, use 'default'.
const isValid = families.length === 0 || families.some((f) => f.id === rawFamily);
const family = isValid ? rawFamily : 'default';
// When the derived family differs from raw (i.e. fallback occurred),
// sync DOM attribute and localStorage. This effect does NOT call setState,
// avoiding the cascading-render lint error.
useEffect(() => {
if (family !== rawFamily) {
document.documentElement.setAttribute('data-theme-family', family);
try {
localStorage.setItem(STORAGE_KEY, family);
} catch {
// localStorage may be unavailable
}
}
}, [family, rawFamily]);
const setFamily = useCallback((id: string) => {
setFamilyState(id);
// Sync to DOM immediately for instant visual update
document.documentElement.setAttribute('data-theme-family', id);
// Persist to localStorage (sole source of truth for theme family)
try {
localStorage.setItem(STORAGE_KEY, id);
} catch {
// localStorage may be unavailable
}
}, []);
return (
<ThemeFamilyContext.Provider value={{ family, setFamily, families }}>
{children}
</ThemeFamilyContext.Provider>
);
}
+1 -1
View File
@@ -11,7 +11,7 @@ export function UpdateBanner() {
if (!updateInfo?.isNativeUpdate || !updateInfo.readyToInstall) return null;
return (
<div className="flex items-center justify-center gap-3 border-b border-blue-500/20 bg-blue-500/10 px-4 py-1.5 text-sm">
<div className="flex items-center justify-center gap-3 border-b border-primary/20 bg-primary/10 px-4 py-1.5 text-sm">
<span>{t('update.readyToInstall', { version: updateInfo.latestVersion })}</span>
<Button size="sm" variant="outline" className="h-6 text-xs" onClick={quitAndInstall}>
{t('update.restartNow')}
+2 -2
View File
@@ -53,7 +53,7 @@ export function UpdateDialog() {
ol: ({ children }) => <ol className="mb-2 list-decimal pl-4 text-sm">{children}</ol>,
li: ({ children }) => <li className="mb-0.5">{children}</li>,
a: ({ href, children }) => (
<a href={href} target="_blank" rel="noopener noreferrer" className="text-blue-500 underline">
<a href={href} target="_blank" rel="noopener noreferrer" className="text-primary underline">
{children}
</a>
),
@@ -88,7 +88,7 @@ export function UpdateDialog() {
<div className="space-y-1">
<div className="h-2 w-full overflow-hidden rounded-full bg-muted">
<div
className="h-full rounded-full bg-blue-500 transition-all"
className="h-full rounded-full bg-primary transition-all"
style={{ width: `${Math.min(downloadProgress!, 100)}%` }}
/>
</div>
+1 -1
View File
@@ -277,7 +277,7 @@ export function McpManager() {
<span className={`h-2 w-2 rounded-full shrink-0 ${
s.status === 'connected' ? 'bg-green-500' :
s.status === 'failed' ? 'bg-red-500' :
s.status === 'pending' ? 'bg-blue-500' :
s.status === 'pending' ? 'bg-primary' :
s.status === 'disabled' ? 'bg-gray-400' :
'bg-yellow-500'
}`} />
+2 -2
View File
@@ -28,7 +28,7 @@ function getServerTypeInfo(server: MCPServer) {
const type = server.type || 'stdio';
switch (type) {
case 'sse':
return { label: 'SSE', icon: Wifi01Icon, color: 'text-blue-500' };
return { label: 'SSE', icon: Wifi01Icon, color: 'text-primary' };
case 'http':
return { label: 'HTTP', icon: GlobeIcon, color: 'text-green-500' };
default:
@@ -45,7 +45,7 @@ function getStatusBadge(status: McpRuntimeStatus['status']) {
case 'needs-auth':
return { label: 'Auth Required', className: 'bg-yellow-500/10 text-yellow-600 border-yellow-500/20' };
case 'pending':
return { label: 'Pending', className: 'bg-blue-500/10 text-blue-600 border-blue-500/20' };
return { label: 'Pending', className: 'bg-primary/10 text-primary border-primary/20' };
case 'disabled':
return { label: 'Disabled', className: 'bg-gray-500/10 text-gray-500 border-gray-500/20' };
default:
+15 -3
View File
@@ -6,9 +6,19 @@ import { ArrowLeft01Icon, Copy01Icon, Tick01Icon, Loading02Icon } from "@hugeico
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { ScrollArea } from "@/components/ui/scroll-area";
import { useTheme } from "next-themes";
import { Light as SyntaxHighlighter } from "react-syntax-highlighter";
import { atomOneDark } from "react-syntax-highlighter/dist/esm/styles/hljs";
import { useThemeFamily } from "@/lib/theme/context";
import { resolveCodeTheme, resolveHljsStyle } from "@/lib/theme/code-themes";
import { usePanel } from "@/hooks/usePanel";
function useFilePreviewCodeTheme() {
const { resolvedTheme } = useTheme();
const { family, families } = useThemeFamily();
const isDark = resolvedTheme === "dark";
const codeTheme = resolveCodeTheme(families, family);
return resolveHljsStyle(codeTheme, isDark);
}
import { useTranslation } from "@/hooks/useTranslation";
import type { FilePreview as FilePreviewType } from "@/types";
@@ -20,6 +30,7 @@ interface FilePreviewProps {
export function FilePreview({ filePath, onBack }: FilePreviewProps) {
const { workingDirectory } = usePanel();
const { t } = useTranslation();
const hljsStyle = useFilePreviewCodeTheme();
const [preview, setPreview] = useState<FilePreviewType | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
@@ -119,7 +130,7 @@ export function FilePreview({ filePath, onBack }: FilePreviewProps) {
<div className="rounded-md border border-border text-xs">
<SyntaxHighlighter
language={preview.language}
style={atomOneDark}
style={hljsStyle}
showLineNumbers
customStyle={{
margin: 0,
@@ -131,7 +142,8 @@ export function FilePreview({ filePath, onBack }: FilePreviewProps) {
lineNumberStyle={{
minWidth: "2.5em",
paddingRight: "8px",
color: "#636d83",
color: "var(--muted-foreground)",
opacity: 0.5,
userSelect: "none",
}}
>
+1 -1
View File
@@ -15,7 +15,7 @@ interface TaskCardProps {
const statusColors: Record<TaskStatus, string> = {
pending: "bg-yellow-500",
in_progress: "bg-blue-500",
in_progress: "bg-primary",
completed: "bg-green-500",
failed: "bg-red-500",
};
@@ -0,0 +1,226 @@
"use client";
import { useEffect, useState, useSyncExternalStore } from "react";
import { useTheme } from "next-themes";
import { codeToHtml, type BundledTheme } from "shiki";
import { useThemeFamily } from "@/lib/theme/context";
import {
resolveShikiTheme,
resolveShikiThemes,
} from "@/lib/theme/code-themes";
import { useTranslation } from "@/hooks/useTranslation";
import { HugeiconsIcon } from "@hugeicons/react";
import { Sun02Icon, Moon02Icon, ComputerDesk01Icon } from "@hugeicons/core-free-icons";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { cn } from "@/lib/utils";
// ── Theme Mode Pill Selector ────────────────────────────────────────
const MODE_OPTIONS = [
{ value: "light", icon: Sun02Icon, labelKey: "settings.modeLight" as const },
{ value: "dark", icon: Moon02Icon, labelKey: "settings.modeDark" as const },
{ value: "system", icon: ComputerDesk01Icon, labelKey: "settings.modeSystem" as const },
] as const;
function ThemeModePills({
value,
onChange,
}: {
value: string;
onChange: (v: string) => void;
}) {
const { t } = useTranslation();
return (
<div className="flex items-center rounded-lg border border-border/50 p-1 gap-1" role="radiogroup">
{MODE_OPTIONS.map((opt) => {
const selected = value === opt.value;
return (
<button
key={opt.value}
role="radio"
aria-checked={selected}
onClick={() => onChange(opt.value)}
className={cn(
"flex items-center gap-1.5 rounded-md px-3 py-1.5 text-xs font-medium transition-all",
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
selected
? "bg-primary text-primary-foreground shadow-sm"
: "text-muted-foreground hover:bg-accent hover:text-accent-foreground"
)}
>
<HugeiconsIcon icon={opt.icon} className="h-3.5 w-3.5" />
{t(opt.labelKey)}
</button>
);
})}
</div>
);
}
// ── Shiki Code Preview ──────────────────────────────────────────────
const PREVIEW_CODE = `function greet(name: string) {
const time = new Date().getHours();
if (time < 12) return \`Good morning, \${name}\`;
return \`Hello, \${name}\`;
}`;
function ShikiCodePreview({ isDark }: { isDark: boolean }) {
const { family, families } = useThemeFamily();
const shikiMapping = resolveShikiTheme(families, family);
const { light, dark } = resolveShikiThemes(shikiMapping);
const theme: BundledTheme = isDark ? dark : light;
const [html, setHtml] = useState("");
useEffect(() => {
let cancelled = false;
codeToHtml(PREVIEW_CODE, {
lang: "typescript",
theme,
}).then((result) => {
if (!cancelled) setHtml(result);
}).catch(() => {});
return () => { cancelled = true; };
}, [theme]);
return (
<div className="rounded-md border border-border overflow-hidden">
<div className="flex items-center justify-between px-3 py-1.5 text-xs bg-muted text-muted-foreground">
<span className="font-medium">preview.ts</span>
<span className="rounded bg-accent px-1.5 py-0.5 text-accent-foreground">TypeScript</span>
</div>
{html ? (
<div
className="shiki-preview [&_pre]:!m-0 [&_pre]:!rounded-none [&_pre]:!text-xs [&_pre]:!leading-relaxed [&_pre]:!p-2 [&_code]:!text-xs"
dangerouslySetInnerHTML={{ __html: html }}
/>
) : (
<div className="h-24 flex items-center justify-center text-xs text-muted-foreground">
Loading…
</div>
)}
</div>
);
}
// ── UI Token Preview ────────────────────────────────────────────────
function UIPreview() {
return (
<div className="flex flex-wrap gap-2">
<button className="rounded-md bg-primary px-3 py-1 text-xs font-medium text-primary-foreground">
Primary
</button>
<button className="rounded-md bg-secondary px-3 py-1 text-xs font-medium text-secondary-foreground">
Secondary
</button>
<button className="rounded-md bg-destructive px-3 py-1 text-xs font-medium text-primary-foreground">
Destructive
</button>
<span className="inline-flex items-center rounded-full bg-accent px-2.5 py-0.5 text-[10px] font-medium text-accent-foreground">
Badge
</span>
<span className="inline-flex items-center rounded-full border border-border bg-card px-2.5 py-0.5 text-[10px] text-card-foreground">
Card
</span>
<span className="inline-flex items-center rounded-full bg-muted px-2.5 py-0.5 text-[10px] text-muted-foreground">
Muted
</span>
</div>
);
}
// ── Main Appearance Section ─────────────────────────────────────────
export function AppearanceSection() {
const { theme, setTheme, resolvedTheme } = useTheme();
const { family, setFamily, families } = useThemeFamily();
const { t } = useTranslation();
const isDark = resolvedTheme === "dark";
const mounted = useSyncExternalStore(
() => () => {},
() => true,
() => false
);
if (!mounted) return null;
return (
<div className="space-y-4">
{/* Section header — outside card */}
<div>
<h2 className="text-sm font-medium">{t("settings.appearance")}</h2>
<p className="text-xs text-muted-foreground">{t("settings.appearanceDesc")}</p>
</div>
<div className="rounded-lg border border-border/50 p-4 space-y-4 transition-shadow hover:shadow-sm">
{/* Mode */}
<div className="flex items-center justify-between gap-4">
<div className="min-w-0">
<h3 className="text-xs font-medium">{t("settings.themeMode")}</h3>
<p className="text-[11px] text-muted-foreground">{t("settings.themeModeDesc")}</p>
</div>
<ThemeModePills value={theme || "system"} onChange={setTheme} />
</div>
{theme === "system" && resolvedTheme && (
<p className="text-[11px] text-muted-foreground pl-1">
{resolvedTheme === "dark" ? t("settings.modeDark") : t("settings.modeLight")}
</p>
)}
<div className="border-t border-border/30" />
{/* Family */}
<div className="flex items-center justify-between gap-4">
<div className="min-w-0">
<h3 className="text-xs font-medium">{t("settings.themeFamily")}</h3>
<p className="text-[11px] text-muted-foreground">{t("settings.themeFamilyDesc")}</p>
</div>
<Select value={family} onValueChange={setFamily}>
<SelectTrigger className="w-[200px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
{families.map((f) => (
<SelectItem key={f.id} value={f.id}>
<span className="flex items-center gap-2">
{f.previewColors && (
<span className="flex gap-0.5">
<span
className="inline-block h-3 w-3 rounded-full border border-border/30"
style={{ background: f.previewColors.primaryLight }}
/>
<span
className="inline-block h-3 w-3 rounded-full border border-border/30"
style={{ background: f.previewColors.primaryDark }}
/>
</span>
)}
<span className="text-xs">{f.label}</span>
</span>
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="border-t border-border/30" />
{/* Preview */}
<div className="space-y-3">
<h3 className="text-xs font-medium text-muted-foreground">Preview</h3>
<UIPreview />
<ShikiCodePreview isDark={isDark} />
</div>
</div>
</div>
);
}
+16 -11
View File
@@ -20,6 +20,7 @@ import { useTranslation } from "@/hooks/useTranslation";
import { SUPPORTED_LOCALES, type Locale } from "@/i18n";
import type { TranslationKey } from "@/i18n";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { AppearanceSection } from "./AppearanceSection";
function UpdateCard() {
const { updateInfo, checking, checkForUpdates, downloadUpdate, quitAndInstall, setShowDialog } = useUpdate();
@@ -75,7 +76,7 @@ function UpdateCard() {
{updateInfo.updateAvailable ? (
<div className="space-y-2">
<div className="flex items-center gap-2">
<span className={`h-2 w-2 rounded-full ${updateInfo.readyToInstall ? 'bg-green-500' : isDownloading ? 'bg-yellow-500 animate-pulse' : 'bg-blue-500'}`} />
<span className={`h-2 w-2 rounded-full ${updateInfo.readyToInstall ? 'bg-green-500' : isDownloading ? 'bg-yellow-500 animate-pulse' : 'bg-primary'}`} />
<span className="text-sm">
{updateInfo.readyToInstall
? t('update.readyToInstall', { version: updateInfo.latestVersion })
@@ -98,7 +99,7 @@ function UpdateCard() {
{isDownloading && (
<div className="h-1.5 w-full overflow-hidden rounded-full bg-muted">
<div
className="h-full rounded-full bg-blue-500 transition-all"
className="h-full rounded-full bg-primary transition-all"
style={{ width: `${Math.min(updateInfo.downloadProgress!, 100)}%` }}
/>
</div>
@@ -209,8 +210,9 @@ export function GeneralSection() {
<div className="max-w-3xl space-y-6">
<UpdateCard />
{/* Auto-approve toggle */}
<div className={`rounded-lg border p-4 transition-shadow hover:shadow-sm ${skipPermissions ? "border-orange-500/50 bg-orange-500/5" : "border-border/50"}`}>
{/* General settings card */}
<div className={`rounded-lg border p-4 space-y-4 transition-shadow hover:shadow-sm ${skipPermissions ? "border-orange-500/50 bg-orange-500/5" : "border-border/50"}`}>
{/* Auto-approve toggle */}
<div className="flex items-center justify-between">
<div>
<h2 className="text-sm font-medium">{t('settings.autoApproveTitle')}</h2>
@@ -225,15 +227,15 @@ export function GeneralSection() {
/>
</div>
{skipPermissions && (
<div className="mt-3 flex items-center gap-2 rounded-md bg-orange-500/10 px-3 py-2 text-xs text-orange-600 dark:text-orange-400">
<div className="flex items-center gap-2 rounded-md bg-orange-500/10 px-3 py-2 text-xs text-orange-600 dark:text-orange-400">
<span className="h-2 w-2 shrink-0 rounded-full bg-orange-500" />
{t('settings.autoApproveWarning')}
</div>
)}
</div>
{/* Language picker */}
<div className="rounded-lg border border-border/50 p-4 transition-shadow hover:shadow-sm">
<div className="border-t border-border/30" />
{/* Language picker */}
<div className="flex items-center justify-between">
<div>
<h2 className="text-sm font-medium">{t('settings.language')}</h2>
@@ -250,10 +252,10 @@ export function GeneralSection() {
</SelectContent>
</Select>
</div>
</div>
{/* Thinking mode */}
<div className="rounded-lg border border-border/50 p-4 transition-shadow hover:shadow-sm">
<div className="border-t border-border/30" />
{/* Thinking mode */}
<div className="flex items-center justify-between">
<div>
<h2 className="text-sm font-medium">{t('settings.thinkingMode' as TranslationKey)}</h2>
@@ -272,6 +274,9 @@ export function GeneralSection() {
</div>
</div>
{/* Appearance */}
<AppearanceSection />
{/* Account info */}
{accountInfo && (
<div className="rounded-lg border border-border/50 p-4 transition-shadow hover:shadow-sm">
+1 -1
View File
@@ -134,7 +134,7 @@ export function CreateSkillDialog({
className={cn(
"flex-1 flex items-center gap-2 rounded-md border px-3 py-2 text-sm transition-colors",
scope === "project"
? "border-blue-500/50 bg-blue-500/10 text-blue-600 dark:text-blue-400"
? "border-primary/50 bg-primary/10 text-primary"
: "border-border hover:bg-accent"
)}
>
@@ -140,7 +140,7 @@ export function InstallProgressDialog({
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
{phase === "running" && (
<HugeiconsIcon icon={Loading02Icon} className="h-5 w-5 animate-spin text-blue-500" />
<HugeiconsIcon icon={Loading02Icon} className="h-5 w-5 animate-spin text-primary" />
)}
{phase === "success" && (
<HugeiconsIcon icon={CheckmarkCircle02Icon} className="h-5 w-5 text-green-500" />
+2 -2
View File
@@ -126,8 +126,8 @@ export function SkillEditor({ skill, onSave, onDelete }: SkillEditorProps) {
: skill.source === "installed"
? "border-orange-500/40 text-orange-600 dark:text-orange-400"
: skill.source === "plugin"
? "border-indigo-500/40 text-indigo-600 dark:text-indigo-400"
: "border-blue-500/40 text-blue-600 dark:text-blue-400"
? "border-primary/40 text-primary"
: "border-primary/40 text-primary"
)}
>
{skill.source === "global" ? (
+16
View File
@@ -0,0 +1,16 @@
import { useTheme } from 'next-themes';
import { useThemeFamily } from '@/lib/theme/context';
export function useAppTheme() {
const { theme, setTheme, resolvedTheme } = useTheme();
const { family, setFamily, families } = useThemeFamily();
return {
mode: theme,
setMode: setTheme,
resolvedMode: resolvedTheme,
family,
setFamily,
families,
};
}
+11
View File
@@ -85,6 +85,17 @@ const en = {
'settings.languageDesc': 'Choose the display language for the interface',
'settings.usage': 'Usage',
// ── Settings: Appearance ──────────────────────────────────────
'settings.appearance': 'Appearance',
'settings.appearanceDesc': 'Customize the look and feel of CodePilot',
'settings.themeMode': 'Theme Mode',
'settings.themeModeDesc': 'Choose between light, dark, or system preference',
'settings.themeFamily': 'Color Theme',
'settings.themeFamilyDesc': 'Select a color palette for the interface',
'settings.modeLight': 'Light',
'settings.modeDark': 'Dark',
'settings.modeSystem': 'System',
// ── Settings: Usage Stats ───────────────────────────────────
'usage.totalTokens': 'Total Tokens',
'usage.totalCost': 'Total Cost',
+11
View File
@@ -82,6 +82,17 @@ const zh: Record<TranslationKey, string> = {
'settings.languageDesc': '选择界面显示语言',
'settings.usage': '用量统计',
// ── Settings: Appearance ──────────────────────────────────────
'settings.appearance': '外观',
'settings.appearanceDesc': '自定义 CodePilot 的外观和风格',
'settings.themeMode': '主题模式',
'settings.themeModeDesc': '选择浅色、深色或跟随系统',
'settings.themeFamily': '颜色主题',
'settings.themeFamilyDesc': '为界面选择一套配色方案',
'settings.modeLight': '浅色',
'settings.modeDark': '深色',
'settings.modeSystem': '跟随系统',
// ── Settings: Usage Stats ───────────────────────────────────
'usage.totalTokens': '总 Token',
'usage.totalCost': '总费用',
+191
View File
@@ -0,0 +1,191 @@
/**
* Centralized code theme mappings for Prism, HLJS, and Shiki.
*
* Each theme family JSON can specify `codeTheme: { light, dark }` and
* `shikiTheme: { light, dark }` using the keys defined below.
* Components should use the hooks exported here rather than maintaining
* local maps.
*/
import type { CSSProperties } from 'react';
import type { BundledTheme } from 'shiki';
// ── Prism (react-syntax-highlighter/prism) ──────────────────────────────
import {
oneDark,
oneLight,
vscDarkPlus,
nord as nordPrism,
solarizedDarkAtom,
solarizedlight as solarizedLightPrism,
nightOwl as nightOwlPrism,
dracula as draculaPrism,
gruvboxDark as gruvboxDarkPrism,
gruvboxLight as gruvboxLightPrism,
ghcolors,
synthwave84 as synthwave84Prism,
materialDark as materialDarkPrism,
materialOceanic as materialOceanicPrism,
duotoneSea as duotoneSeaPrism,
coldarkDark as coldarkDarkPrism,
coldarkCold as coldarkColdPrism,
materialLight as materialLightPrism,
duotoneLight as duotoneLightPrism,
vs as vsPrism,
coy as coyPrism,
prism as prismDefault,
duotoneEarth as duotoneEarthPrism,
duotoneForest as duotoneForestPrism,
twilight as twilightPrism,
} from 'react-syntax-highlighter/dist/esm/styles/prism';
type SyntaxStyle = Record<string, CSSProperties>;
/** Prism theme name → style object. Keys match `codeTheme.dark` / `codeTheme.light` in theme JSONs. */
export const PRISM_THEME_MAP: Record<string, SyntaxStyle> = {
oneDark,
oneLight,
vscDarkPlus,
nord: nordPrism,
solarizedDarkAtom,
solarizedlight: solarizedLightPrism,
nightOwl: nightOwlPrism,
dracula: draculaPrism,
gruvboxDark: gruvboxDarkPrism,
gruvboxLight: gruvboxLightPrism,
ghcolors,
synthwave84: synthwave84Prism,
materialDark: materialDarkPrism,
materialOceanic: materialOceanicPrism,
duotoneSea: duotoneSeaPrism,
coldarkDark: coldarkDarkPrism,
coldarkCold: coldarkColdPrism,
materialLight: materialLightPrism,
duotoneLight: duotoneLightPrism,
vs: vsPrism,
coy: coyPrism,
prism: prismDefault,
duotoneEarth: duotoneEarthPrism,
duotoneForest: duotoneForestPrism,
twilight: twilightPrism,
};
export const PRISM_DEFAULT_DARK: SyntaxStyle = oneDark;
export const PRISM_DEFAULT_LIGHT: SyntaxStyle = oneLight;
// ── HLJS (react-syntax-highlighter/hljs) ────────────────────────────────
import {
atomOneDark,
atomOneLight,
nord as nordHljs,
solarizedDark,
solarizedLight,
nightOwl as nightOwlHljs,
dracula as draculaHljs,
gruvboxDark as gruvboxDarkHljs,
gruvboxLight as gruvboxLightHljs,
github as githubHljs,
vs2015 as vs2015Hljs,
monokaiSublime as monokaiSublimeHljs,
vs as vsHljs,
idea as ideaHljs,
lightfair as lightfairHljs,
xcode as xcodeHljs,
zenburn as zenburnHljs,
darcula as darculaHljs,
obsidian as obsidianHljs,
} from 'react-syntax-highlighter/dist/esm/styles/hljs';
type HljsStyle = Record<string, CSSProperties>;
/** HLJS theme name → style object. Same keys as Prism map where possible. */
export const HLJS_THEME_MAP: Record<string, HljsStyle> = {
oneDark: atomOneDark,
oneLight: atomOneLight,
nord: nordHljs,
solarizedDarkAtom: solarizedDark,
solarizedlight: solarizedLight,
nightOwl: nightOwlHljs,
dracula: draculaHljs,
gruvboxDark: gruvboxDarkHljs,
gruvboxLight: gruvboxLightHljs,
ghcolors: githubHljs,
synthwave84: monokaiSublimeHljs,
materialDark: vs2015Hljs,
materialOceanic: atomOneDark,
duotoneSea: atomOneDark,
coldarkDark: atomOneDark,
coldarkCold: atomOneLight,
materialLight: ideaHljs,
duotoneLight: atomOneLight,
vs: vsHljs,
coy: xcodeHljs,
prism: lightfairHljs,
duotoneEarth: darculaHljs,
duotoneForest: zenburnHljs,
twilight: obsidianHljs,
};
export const HLJS_DEFAULT_DARK: HljsStyle = atomOneDark;
export const HLJS_DEFAULT_LIGHT: HljsStyle = atomOneLight;
// ── Shiki ───────────────────────────────────────────────────────────────
export const SHIKI_DEFAULT_LIGHT: BundledTheme = 'github-light';
export const SHIKI_DEFAULT_DARK: BundledTheme = 'github-dark';
// ── Resolution helpers ──────────────────────────────────────────────────
import type { ThemeFamilyMeta, CodeThemeMapping } from './types';
/**
* Resolve a code-theme mapping from the current family metadata.
* Returns `undefined` if family has no `codeTheme`.
*/
export function resolveCodeTheme(
families: ThemeFamilyMeta[],
familyId: string,
): CodeThemeMapping | undefined {
return families.find((f) => f.id === familyId)?.codeTheme;
}
/**
* Resolve a shiki-theme mapping from the current family metadata.
* Returns `undefined` if family has no `shikiTheme`.
*/
export function resolveShikiTheme(
families: ThemeFamilyMeta[],
familyId: string,
): CodeThemeMapping | undefined {
return families.find((f) => f.id === familyId)?.shikiTheme;
}
/** Pick a Prism style for the given mode. Falls back to oneDark / oneLight. */
export function resolvePrismStyle(
codeTheme: CodeThemeMapping | undefined,
isDark: boolean,
): SyntaxStyle {
const name = isDark ? codeTheme?.dark : codeTheme?.light;
if (name && PRISM_THEME_MAP[name]) return PRISM_THEME_MAP[name];
return isDark ? PRISM_DEFAULT_DARK : PRISM_DEFAULT_LIGHT;
}
/** Pick an HLJS style for the given mode. Falls back to atomOneDark / atomOneLight. */
export function resolveHljsStyle(
codeTheme: CodeThemeMapping | undefined,
isDark: boolean,
): HljsStyle {
const name = isDark ? codeTheme?.dark : codeTheme?.light;
if (name && HLJS_THEME_MAP[name]) return HLJS_THEME_MAP[name];
return isDark ? HLJS_DEFAULT_DARK : HLJS_DEFAULT_LIGHT;
}
/** Pick a Shiki BundledTheme pair. Falls back to github-light / github-dark. */
export function resolveShikiThemes(
shikiTheme: CodeThemeMapping | undefined,
): { light: BundledTheme; dark: BundledTheme } {
return {
light: (shikiTheme?.light ?? SHIKI_DEFAULT_LIGHT) as BundledTheme,
dark: (shikiTheme?.dark ?? SHIKI_DEFAULT_DARK) as BundledTheme,
};
}
+18
View File
@@ -0,0 +1,18 @@
import { createContext, useContext } from 'react';
import type { ThemeFamilyMeta } from './types';
export interface ThemeFamilyContextValue {
family: string;
setFamily: (id: string) => void;
families: ThemeFamilyMeta[];
}
export const ThemeFamilyContext = createContext<ThemeFamilyContextValue>({
family: 'default',
setFamily: () => {},
families: [],
});
export function useThemeFamily() {
return useContext(ThemeFamilyContext);
}
+214
View File
@@ -0,0 +1,214 @@
import fs from 'fs';
import path from 'path';
import type { ThemeFamily, ThemeFamilyMeta, ThemeColors } from './types';
/** Hardcoded fallback matching globals.css :root values. */
const FALLBACK_LIGHT: ThemeColors = {
background: 'oklch(1 0 0)',
foreground: 'oklch(0.147 0.004 49.25)',
card: 'oklch(1 0 0)',
cardForeground: 'oklch(0.147 0.004 49.25)',
popover: 'oklch(1 0 0)',
popoverForeground: 'oklch(0.147 0.004 49.25)',
primary: 'oklch(0.546 0.245 262.881)',
primaryForeground: 'oklch(0.985 0.001 106.423)',
secondary: 'oklch(0.97 0.001 106.424)',
secondaryForeground: 'oklch(0.216 0.006 56.043)',
muted: 'oklch(0.97 0.001 106.424)',
mutedForeground: 'oklch(0.553 0.013 58.071)',
accent: 'oklch(0.97 0.001 106.424)',
accentForeground: 'oklch(0.216 0.006 56.043)',
destructive: 'oklch(0.577 0.245 27.325)',
border: 'oklch(0.923 0.003 48.717)',
input: 'oklch(0.923 0.003 48.717)',
ring: 'oklch(0.546 0.245 262.881)',
chart1: 'oklch(0.546 0.245 262.881)',
chart2: 'oklch(0.6 0.118 184.704)',
chart3: 'oklch(0.398 0.07 227.392)',
chart4: 'oklch(0.828 0.189 84.429)',
chart5: 'oklch(0.769 0.188 70.08)',
sidebar: 'oklch(0.985 0.001 106.423)',
sidebarForeground: 'oklch(0.147 0.004 49.25)',
sidebarPrimary: 'oklch(0.546 0.245 262.881)',
sidebarPrimaryForeground: 'oklch(0.985 0.001 106.423)',
sidebarAccent: 'oklch(0.97 0.001 106.424)',
sidebarAccentForeground: 'oklch(0.216 0.006 56.043)',
sidebarBorder: 'oklch(0.923 0.003 48.717)',
sidebarRing: 'oklch(0.546 0.245 262.881)',
};
const FALLBACK_DARK: ThemeColors = {
background: 'oklch(0.147 0.004 49.25)',
foreground: 'oklch(0.985 0.001 106.423)',
card: 'oklch(0.216 0.006 56.043)',
cardForeground: 'oklch(0.985 0.001 106.423)',
popover: 'oklch(0.216 0.006 56.043)',
popoverForeground: 'oklch(0.985 0.001 106.423)',
primary: 'oklch(0.623 0.214 259.815)',
primaryForeground: 'oklch(0.985 0.001 106.423)',
secondary: 'oklch(0.268 0.007 34.298)',
secondaryForeground: 'oklch(0.985 0.001 106.423)',
muted: 'oklch(0.268 0.007 34.298)',
mutedForeground: 'oklch(0.709 0.01 56.259)',
accent: 'oklch(0.268 0.007 34.298)',
accentForeground: 'oklch(0.985 0.001 106.423)',
destructive: 'oklch(0.704 0.191 22.216)',
border: 'oklch(1 0 0 / 10%)',
input: 'oklch(1 0 0 / 15%)',
ring: 'oklch(0.623 0.214 259.815)',
chart1: 'oklch(0.623 0.214 259.815)',
chart2: 'oklch(0.696 0.17 162.48)',
chart3: 'oklch(0.769 0.188 70.08)',
chart4: 'oklch(0.627 0.265 303.9)',
chart5: 'oklch(0.645 0.246 16.439)',
sidebar: 'oklch(0.216 0.006 56.043)',
sidebarForeground: 'oklch(0.985 0.001 106.423)',
sidebarPrimary: 'oklch(0.623 0.214 259.815)',
sidebarPrimaryForeground: 'oklch(0.985 0.001 106.423)',
sidebarAccent: 'oklch(0.268 0.007 34.298)',
sidebarAccentForeground: 'oklch(0.985 0.001 106.423)',
sidebarBorder: 'oklch(1 0 0 / 10%)',
sidebarRing: 'oklch(0.623 0.214 259.815)',
};
/** Every key in ThemeColors is required — must match types.ts exactly. */
const REQUIRED_COLOR_KEYS: (keyof ThemeColors)[] = [
'background', 'foreground', 'card', 'cardForeground',
'popover', 'popoverForeground', 'primary', 'primaryForeground',
'secondary', 'secondaryForeground', 'muted', 'mutedForeground',
'accent', 'accentForeground', 'destructive', 'border', 'input', 'ring',
'chart1', 'chart2', 'chart3', 'chart4', 'chart5',
'sidebar', 'sidebarForeground', 'sidebarPrimary', 'sidebarPrimaryForeground',
'sidebarAccent', 'sidebarAccentForeground', 'sidebarBorder', 'sidebarRing',
];
function isValidThemeColors(obj: unknown): obj is ThemeColors {
if (!obj || typeof obj !== 'object') return false;
const record = obj as Record<string, unknown>;
return REQUIRED_COLOR_KEYS.every(
(key) => typeof record[key] === 'string' && record[key] !== ''
);
}
/** Check whether a value is a valid { light: string, dark: string } mapping. */
function isValidCodeThemeMapping(obj: unknown): boolean {
if (!obj || typeof obj !== 'object') return false;
const rec = obj as Record<string, unknown>;
return typeof rec.light === 'string' && rec.light !== '' &&
typeof rec.dark === 'string' && rec.dark !== '';
}
function isValidThemeFamily(obj: unknown): obj is ThemeFamily {
if (!obj || typeof obj !== 'object') return false;
const record = obj as Record<string, unknown>;
return (
typeof record.id === 'string' &&
typeof record.label === 'string' &&
typeof record.order === 'number' &&
isValidThemeColors(record.light) &&
isValidThemeColors(record.dark)
);
}
/**
* Strip invalid codeTheme / shikiTheme from a loaded theme.
* This way a bad mapping doesn't break the whole theme; consumers fall back to defaults.
*/
function sanitizeThemeMappings(theme: ThemeFamily): void {
if (theme.codeTheme !== undefined && !isValidCodeThemeMapping(theme.codeTheme)) {
console.warn(`[theme-loader] Stripping invalid codeTheme from theme "${theme.id}"`);
delete theme.codeTheme;
}
if (theme.shikiTheme !== undefined && !isValidCodeThemeMapping(theme.shikiTheme)) {
console.warn(`[theme-loader] Stripping invalid shikiTheme from theme "${theme.id}"`);
delete theme.shikiTheme;
}
}
/** Resolve the themes directory. Works for both dev and Electron packaged app. */
function resolveThemesDir(): string {
// In Electron packaged app, process.resourcesPath points to resources/
// and themes are at standalone/themes/ inside resources.
const electronPath = process.env.RESOURCES_PATH
? path.join(process.env.RESOURCES_PATH, 'standalone', 'themes')
: null;
const devPath = path.resolve(process.cwd(), 'themes');
if (electronPath && fs.existsSync(electronPath)) return electronPath;
return devPath;
}
// Module-level cache (runs once per process)
let cachedFamilies: ThemeFamily[] | null = null;
/** Reset the module-level cache. Test-only. */
export function _resetCache() {
cachedFamilies = null;
}
/** Load and validate all theme families from themes/*.json. */
export function getAllThemeFamilies(): ThemeFamily[] {
if (cachedFamilies) return cachedFamilies;
const themesDir = resolveThemesDir();
const families: ThemeFamily[] = [];
try {
if (!fs.existsSync(themesDir)) {
console.warn(`[theme-loader] Themes directory not found: ${themesDir}`);
} else {
const files = fs.readdirSync(themesDir).filter((f) => f.endsWith('.json'));
for (const file of files) {
try {
const raw = fs.readFileSync(path.join(themesDir, file), 'utf-8');
const parsed = JSON.parse(raw);
if (isValidThemeFamily(parsed)) {
sanitizeThemeMappings(parsed);
families.push(parsed);
} else {
console.warn(`[theme-loader] Skipping invalid theme file: ${file}`);
}
} catch (err) {
console.warn(`[theme-loader] Failed to parse ${file}:`, err);
}
}
}
} catch (err) {
console.warn('[theme-loader] Error reading themes directory:', err);
}
// Ensure a default family always exists
if (!families.some((f) => f.id === 'default')) {
families.push({
id: 'default',
label: 'Default',
order: 0,
light: FALLBACK_LIGHT,
dark: FALLBACK_DARK,
});
}
families.sort((a, b) => a.order - b.order);
cachedFamilies = families;
return families;
}
/** Lightweight metadata for client-side use. */
export function getThemeFamilyMetas(): ThemeFamilyMeta[] {
return getAllThemeFamilies().map((f) => ({
id: f.id,
label: f.label,
description: f.description,
previewColors: {
primaryLight: f.light.primary,
primaryDark: f.dark.primary,
accentLight: f.light.accent,
backgroundLight: f.light.background,
},
codeTheme: f.codeTheme,
shikiTheme: f.shikiTheme,
}));
}
+46
View File
@@ -0,0 +1,46 @@
import type { ThemeFamily, ThemeColors } from './types';
/** Convert camelCase key to CSS variable name: cardForeground → --card-foreground */
export function colorKeyToCssVar(key: string): string {
// Insert hyphens before uppercase letters, then lowercase
// Special handling for numbered keys like chart1 → --chart-1
const kebab = key
.replace(/([A-Z])/g, '-$1')
.replace(/(\d+)/g, '-$1')
.toLowerCase();
return `--${kebab}`;
}
function renderColorBlock(colors: ThemeColors): string {
const lines: string[] = [];
for (const [key, value] of Object.entries(colors)) {
lines.push(` ${colorKeyToCssVar(key)}: ${value};`);
}
return lines.join('\n');
}
/**
* Generate CSS for all theme families.
*
* Specificity:
* html[data-theme-family="x"] → (0,1,0) — beats :root (0,0,1)
* html.dark[data-theme-family="x"] → (0,2,0) — beats .dark (0,1,0)
*
* The globals.css :root/.dark blocks remain as fallback when data-theme-family is absent.
*/
export function renderThemeFamilyCSS(families: ThemeFamily[]): string {
const blocks: string[] = [];
for (const family of families) {
// Light mode
blocks.push(
`html[data-theme-family="${family.id}"] {\n${renderColorBlock(family.light)}\n}`
);
// Dark mode
blocks.push(
`html.dark[data-theme-family="${family.id}"] {\n${renderColorBlock(family.dark)}\n}`
);
}
return blocks.join('\n\n');
}
+78
View File
@@ -0,0 +1,78 @@
/**
* Theme Family system types.
*
* A "theme family" controls the color palette independently of light/dark mode.
* Mode is handled by next-themes; family is a second layer via CSS variables.
*/
/** All CSS variable keys matching globals.css :root block (camelCase). */
export interface ThemeColors {
background: string;
foreground: string;
card: string;
cardForeground: string;
popover: string;
popoverForeground: string;
primary: string;
primaryForeground: string;
secondary: string;
secondaryForeground: string;
muted: string;
mutedForeground: string;
accent: string;
accentForeground: string;
destructive: string;
border: string;
input: string;
ring: string;
chart1: string;
chart2: string;
chart3: string;
chart4: string;
chart5: string;
sidebar: string;
sidebarForeground: string;
sidebarPrimary: string;
sidebarPrimaryForeground: string;
sidebarAccent: string;
sidebarAccentForeground: string;
sidebarBorder: string;
sidebarRing: string;
}
/** Maps a code highlighting theme name for light and dark modes. */
export interface CodeThemeMapping {
light: string;
dark: string;
}
/** Full theme family definition (loaded from JSON). */
export interface ThemeFamily {
id: string;
label: string;
order: number;
description?: string;
light: ThemeColors;
dark: ThemeColors;
codeTheme?: CodeThemeMapping;
/** Shiki theme names (used by ai-elements code blocks). */
shikiTheme?: CodeThemeMapping;
}
/** Lightweight metadata for client-side use (selector UI). */
export interface ThemeFamilyMeta {
id: string;
label: string;
description?: string;
/** Preview colors for the selector (primary light, primary dark, accent light, background light). */
previewColors?: {
primaryLight: string;
primaryDark: string;
accentLight: string;
backgroundLight: string;
};
/** Code highlighting theme names (keys into react-syntax-highlighter theme maps). */
codeTheme?: CodeThemeMapping;
/** Shiki theme names for ai-elements code blocks. */
shikiTheme?: CodeThemeMapping;
}
+80
View File
@@ -0,0 +1,80 @@
{
"id": "default",
"label": "Default",
"order": 0,
"description": "The default CodePilot color scheme",
"codeTheme": {
"light": "oneLight",
"dark": "oneDark"
},
"shikiTheme": {
"light": "github-light",
"dark": "github-dark"
},
"light": {
"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)"
},
"dark": {
"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)"
}
}
+80
View File
@@ -0,0 +1,80 @@
{
"id": "everforest",
"label": "Everforest",
"order": 4,
"description": "Comfortable green-toned palette inspired by natural landscapes",
"codeTheme": {
"light": "gruvboxLight",
"dark": "duotoneForest"
},
"shikiTheme": {
"light": "everforest-light",
"dark": "everforest-dark"
},
"light": {
"background": "oklch(0.974 0.026 90.1)",
"foreground": "oklch(0.515 0.021 232.9)",
"card": "oklch(0.952 0.031 98.9)",
"cardForeground": "oklch(0.515 0.021 232.9)",
"popover": "oklch(0.952 0.031 98.9)",
"popoverForeground": "oklch(0.515 0.021 232.9)",
"primary": "oklch(0.720 0.122 125.2)",
"primaryForeground": "oklch(0.974 0.026 90.1)",
"secondary": "oklch(0.937 0.031 98.9)",
"secondaryForeground": "oklch(0.515 0.021 232.9)",
"muted": "oklch(0.937 0.031 98.9)",
"mutedForeground": "oklch(0.689 0.024 141.2)",
"accent": "oklch(0.937 0.031 98.9)",
"accentForeground": "oklch(0.515 0.021 232.9)",
"destructive": "oklch(0.671 0.200 25.2)",
"border": "oklch(0.892 0.028 98.1)",
"input": "oklch(0.892 0.028 98.1)",
"ring": "oklch(0.720 0.122 125.2)",
"chart1": "oklch(0.720 0.122 125.2)",
"chart2": "oklch(0.600 0.090 220.0)",
"chart3": "oklch(0.680 0.140 60.0)",
"chart4": "oklch(0.620 0.120 310.0)",
"chart5": "oklch(0.550 0.100 180.0)",
"sidebar": "oklch(0.974 0.026 90.1)",
"sidebarForeground": "oklch(0.515 0.021 232.9)",
"sidebarPrimary": "oklch(0.720 0.122 125.2)",
"sidebarPrimaryForeground": "oklch(0.974 0.026 90.1)",
"sidebarAccent": "oklch(0.937 0.031 98.9)",
"sidebarAccentForeground": "oklch(0.515 0.021 232.9)",
"sidebarBorder": "oklch(0.892 0.028 98.1)",
"sidebarRing": "oklch(0.720 0.122 125.2)"
},
"dark": {
"background": "oklch(0.324 0.015 240.4)",
"foreground": "oklch(0.830 0.041 86.1)",
"card": "oklch(0.360 0.017 227.1)",
"cardForeground": "oklch(0.830 0.041 86.1)",
"popover": "oklch(0.360 0.017 227.1)",
"popoverForeground": "oklch(0.830 0.041 86.1)",
"primary": "oklch(0.773 0.091 125.8)",
"primaryForeground": "oklch(0.324 0.015 240.4)",
"secondary": "oklch(0.394 0.017 227.0)",
"secondaryForeground": "oklch(0.830 0.041 86.1)",
"muted": "oklch(0.394 0.017 227.0)",
"mutedForeground": "oklch(0.647 0.020 155.6)",
"accent": "oklch(0.394 0.017 227.0)",
"accentForeground": "oklch(0.830 0.041 86.1)",
"destructive": "oklch(0.709 0.128 19.6)",
"border": "oklch(0.455 0.015 236.9)",
"input": "oklch(0.394 0.017 227.0)",
"ring": "oklch(0.773 0.091 125.8)",
"chart1": "oklch(0.773 0.091 125.8)",
"chart2": "oklch(0.650 0.090 220.0)",
"chart3": "oklch(0.750 0.130 60.0)",
"chart4": "oklch(0.680 0.120 310.0)",
"chart5": "oklch(0.600 0.100 180.0)",
"sidebar": "oklch(0.324 0.015 240.4)",
"sidebarForeground": "oklch(0.830 0.041 86.1)",
"sidebarPrimary": "oklch(0.773 0.091 125.8)",
"sidebarPrimaryForeground": "oklch(0.324 0.015 240.4)",
"sidebarAccent": "oklch(0.394 0.017 227.0)",
"sidebarAccentForeground": "oklch(0.830 0.041 86.1)",
"sidebarBorder": "oklch(0.268 0.011 237.1)",
"sidebarRing": "oklch(0.773 0.091 125.8)"
}
}
+80
View File
@@ -0,0 +1,80 @@
{
"id": "github",
"label": "GitHub",
"order": 1,
"description": "Clean and familiar, inspired by GitHub's Primer design system",
"codeTheme": {
"light": "ghcolors",
"dark": "vscDarkPlus"
},
"shikiTheme": {
"light": "github-light-default",
"dark": "github-dark-default"
},
"light": {
"background": "oklch(1.000 0 0)",
"foreground": "oklch(0.254 0.011 254.0)",
"card": "oklch(1.000 0 0)",
"cardForeground": "oklch(0.254 0.011 254.0)",
"popover": "oklch(1.000 0 0)",
"popoverForeground": "oklch(0.254 0.011 254.0)",
"primary": "oklch(0.540 0.191 257.5)",
"primaryForeground": "oklch(1.000 0 0)",
"secondary": "oklch(0.947 0.007 247.9)",
"secondaryForeground": "oklch(0.338 0.015 252.3)",
"muted": "oklch(0.947 0.007 247.9)",
"mutedForeground": "oklch(0.531 0.017 251.3)",
"accent": "oklch(0.978 0.003 247.9)",
"accentForeground": "oklch(0.338 0.015 252.3)",
"destructive": "oklch(0.552 0.205 24.5)",
"border": "oklch(0.876 0.012 248.0)",
"input": "oklch(0.876 0.012 248.0)",
"ring": "oklch(0.540 0.191 257.5)",
"chart1": "oklch(0.540 0.191 257.5)",
"chart2": "oklch(0.546 0.147 146.3)",
"chart3": "oklch(0.700 0.160 48.0)",
"chart4": "oklch(0.620 0.165 310.0)",
"chart5": "oklch(0.600 0.120 185.0)",
"sidebar": "oklch(0.978 0.003 247.9)",
"sidebarForeground": "oklch(0.254 0.011 254.0)",
"sidebarPrimary": "oklch(0.540 0.191 257.5)",
"sidebarPrimaryForeground": "oklch(1.000 0 0)",
"sidebarAccent": "oklch(0.947 0.007 247.9)",
"sidebarAccentForeground": "oklch(0.338 0.015 252.3)",
"sidebarBorder": "oklch(0.876 0.012 248.0)",
"sidebarRing": "oklch(0.540 0.191 257.5)"
},
"dark": {
"background": "oklch(0.176 0.014 258.4)",
"foreground": "oklch(0.943 0.011 243.7)",
"card": "oklch(0.220 0.016 256.8)",
"cardForeground": "oklch(0.943 0.011 243.7)",
"popover": "oklch(0.220 0.016 256.8)",
"popoverForeground": "oklch(0.943 0.011 243.7)",
"primary": "oklch(0.569 0.202 259.7)",
"primaryForeground": "oklch(1.000 0 0)",
"secondary": "oklch(0.267 0.015 256.8)",
"secondaryForeground": "oklch(0.857 0.014 248.0)",
"muted": "oklch(0.267 0.015 256.8)",
"mutedForeground": "oklch(0.662 0.018 250.9)",
"accent": "oklch(0.246 0.015 256.8)",
"accentForeground": "oklch(0.943 0.011 243.7)",
"destructive": "oklch(0.665 0.205 27.0)",
"border": "oklch(0.330 0.015 252.3)",
"input": "oklch(0.330 0.015 252.3)",
"ring": "oklch(0.569 0.202 259.7)",
"chart1": "oklch(0.569 0.202 259.7)",
"chart2": "oklch(0.600 0.140 146.0)",
"chart3": "oklch(0.750 0.150 48.0)",
"chart4": "oklch(0.680 0.170 310.0)",
"chart5": "oklch(0.650 0.120 185.0)",
"sidebar": "oklch(0.104 0.019 248.3)",
"sidebarForeground": "oklch(0.943 0.011 243.7)",
"sidebarPrimary": "oklch(0.569 0.202 259.7)",
"sidebarPrimaryForeground": "oklch(1.000 0 0)",
"sidebarAccent": "oklch(0.267 0.015 256.8)",
"sidebarAccentForeground": "oklch(0.943 0.011 243.7)",
"sidebarBorder": "oklch(0.330 0.015 252.3)",
"sidebarRing": "oklch(0.569 0.202 259.7)"
}
}
+80
View File
@@ -0,0 +1,80 @@
{
"id": "horizon",
"label": "Horizon",
"order": 10,
"description": "Warm sunset palette with rosy pink accents",
"codeTheme": {
"light": "duotoneLight",
"dark": "materialDark"
},
"shikiTheme": {
"light": "github-light",
"dark": "horizon"
},
"light": {
"background": "oklch(0.965 0.015 33.1)",
"foreground": "oklch(0.237 0.016 274.1)",
"card": "oklch(0.913 0.038 36.2)",
"cardForeground": "oklch(0.237 0.016 274.1)",
"popover": "oklch(0.913 0.038 36.2)",
"popoverForeground": "oklch(0.237 0.016 274.1)",
"primary": "oklch(0.567 0.222 18.8)",
"primaryForeground": "oklch(0.965 0.015 33.1)",
"secondary": "oklch(0.883 0.045 29.9)",
"secondaryForeground": "oklch(0.237 0.016 274.1)",
"muted": "oklch(0.883 0.045 29.9)",
"mutedForeground": "oklch(0.553 0.056 280.7)",
"accent": "oklch(0.913 0.038 36.2)",
"accentForeground": "oklch(0.237 0.016 274.1)",
"destructive": "oklch(0.567 0.222 18.8)",
"border": "oklch(0.837 0.060 32.6)",
"input": "oklch(0.913 0.038 36.2)",
"ring": "oklch(0.567 0.222 18.8)",
"chart1": "oklch(0.567 0.222 18.8)",
"chart2": "oklch(0.781 0.163 162.1)",
"chart3": "oklch(0.834 0.091 47.9)",
"chart4": "oklch(0.732 0.123 215.7)",
"chart5": "oklch(0.676 0.157 312.8)",
"sidebar": "oklch(0.913 0.038 36.2)",
"sidebarForeground": "oklch(0.237 0.016 274.1)",
"sidebarPrimary": "oklch(0.567 0.222 18.8)",
"sidebarPrimaryForeground": "oklch(0.965 0.015 33.1)",
"sidebarAccent": "oklch(0.883 0.045 29.9)",
"sidebarAccentForeground": "oklch(0.237 0.016 274.1)",
"sidebarBorder": "oklch(0.837 0.060 32.6)",
"sidebarRing": "oklch(0.567 0.222 18.8)"
},
"dark": {
"background": "oklch(0.237 0.016 274.1)",
"foreground": "oklch(0.880 0.004 236.5)",
"card": "oklch(0.267 0.021 276.8)",
"cardForeground": "oklch(0.880 0.004 236.5)",
"popover": "oklch(0.267 0.021 276.8)",
"popoverForeground": "oklch(0.880 0.004 236.5)",
"primary": "oklch(0.653 0.186 9.0)",
"primaryForeground": "oklch(0.965 0.015 33.1)",
"secondary": "oklch(0.313 0.025 278.5)",
"secondaryForeground": "oklch(0.880 0.004 236.5)",
"muted": "oklch(0.313 0.025 278.5)",
"mutedForeground": "oklch(0.553 0.056 280.7)",
"accent": "oklch(0.267 0.021 276.8)",
"accentForeground": "oklch(0.880 0.004 236.5)",
"destructive": "oklch(0.653 0.186 9.0)",
"border": "oklch(0.313 0.025 278.5)",
"input": "oklch(0.267 0.021 276.8)",
"ring": "oklch(0.653 0.186 9.0)",
"chart1": "oklch(0.653 0.186 9.0)",
"chart2": "oklch(0.781 0.163 162.1)",
"chart3": "oklch(0.834 0.091 47.9)",
"chart4": "oklch(0.732 0.123 215.7)",
"chart5": "oklch(0.676 0.157 312.8)",
"sidebar": "oklch(0.237 0.016 274.1)",
"sidebarForeground": "oklch(0.880 0.004 236.5)",
"sidebarPrimary": "oklch(0.653 0.186 9.0)",
"sidebarPrimaryForeground": "oklch(0.965 0.015 33.1)",
"sidebarAccent": "oklch(0.267 0.021 276.8)",
"sidebarAccentForeground": "oklch(0.880 0.004 236.5)",
"sidebarBorder": "oklch(0.267 0.021 276.8)",
"sidebarRing": "oklch(0.653 0.186 9.0)"
}
}
+80
View File
@@ -0,0 +1,80 @@
{
"id": "kanagawa",
"label": "Kanagawa",
"order": 6,
"description": "Ink-wash palette with warm parchment tones",
"codeTheme": {
"light": "vs",
"dark": "duotoneEarth"
},
"shikiTheme": {
"light": "kanagawa-lotus",
"dark": "kanagawa-wave"
},
"light": {
"background": "oklch(0.936 0.063 101.6)",
"foreground": "oklch(0.452 0.026 285.3)",
"card": "oklch(0.887 0.077 98.0)",
"cardForeground": "oklch(0.452 0.026 285.3)",
"popover": "oklch(0.887 0.077 98.0)",
"popoverForeground": "oklch(0.452 0.026 285.3)",
"primary": "oklch(0.521 0.086 261.5)",
"primaryForeground": "oklch(0.958 0.057 101.0)",
"secondary": "oklch(0.868 0.055 99.4)",
"secondaryForeground": "oklch(0.452 0.026 285.3)",
"muted": "oklch(0.868 0.055 99.4)",
"mutedForeground": "oklch(0.628 0.013 101.6)",
"accent": "oklch(0.887 0.077 98.0)",
"accentForeground": "oklch(0.452 0.026 285.3)",
"destructive": "oklch(0.571 0.171 16.5)",
"border": "oklch(0.831 0.083 96.4)",
"input": "oklch(0.887 0.077 98.0)",
"ring": "oklch(0.521 0.086 261.5)",
"chart1": "oklch(0.521 0.086 261.5)",
"chart2": "oklch(0.748 0.112 128.4)",
"chart3": "oklch(0.833 0.090 81.6)",
"chart4": "oklch(0.638 0.087 301.7)",
"chart5": "oklch(0.571 0.171 16.5)",
"sidebar": "oklch(0.958 0.057 101.0)",
"sidebarForeground": "oklch(0.452 0.026 285.3)",
"sidebarPrimary": "oklch(0.521 0.086 261.5)",
"sidebarPrimaryForeground": "oklch(0.958 0.057 101.0)",
"sidebarAccent": "oklch(0.887 0.077 98.0)",
"sidebarAccentForeground": "oklch(0.452 0.026 285.3)",
"sidebarBorder": "oklch(0.831 0.083 96.4)",
"sidebarRing": "oklch(0.521 0.086 261.5)"
},
"dark": {
"background": "oklch(0.243 0.017 285.1)",
"foreground": "oklch(0.876 0.039 99.1)",
"card": "oklch(0.291 0.024 284.8)",
"cardForeground": "oklch(0.876 0.039 99.1)",
"popover": "oklch(0.291 0.024 284.8)",
"popoverForeground": "oklch(0.876 0.039 99.1)",
"primary": "oklch(0.694 0.095 263.7)",
"primaryForeground": "oklch(0.243 0.017 285.1)",
"secondary": "oklch(0.340 0.028 284.8)",
"secondaryForeground": "oklch(0.876 0.039 99.1)",
"muted": "oklch(0.340 0.028 284.8)",
"mutedForeground": "oklch(0.547 0.012 101.0)",
"accent": "oklch(0.291 0.024 284.8)",
"accentForeground": "oklch(0.876 0.039 99.1)",
"destructive": "oklch(0.559 0.167 23.1)",
"border": "oklch(0.340 0.028 284.8)",
"input": "oklch(0.291 0.024 284.8)",
"ring": "oklch(0.694 0.095 263.7)",
"chart1": "oklch(0.694 0.095 263.7)",
"chart2": "oklch(0.748 0.112 128.4)",
"chart3": "oklch(0.833 0.090 81.6)",
"chart4": "oklch(0.638 0.087 301.7)",
"chart5": "oklch(0.693 0.197 22.1)",
"sidebar": "oklch(0.243 0.017 285.1)",
"sidebarForeground": "oklch(0.876 0.039 99.1)",
"sidebarPrimary": "oklch(0.694 0.095 263.7)",
"sidebarPrimaryForeground": "oklch(0.243 0.017 285.1)",
"sidebarAccent": "oklch(0.291 0.024 284.8)",
"sidebarAccentForeground": "oklch(0.876 0.039 99.1)",
"sidebarBorder": "oklch(0.291 0.024 284.8)",
"sidebarRing": "oklch(0.694 0.095 263.7)"
}
}
+80
View File
@@ -0,0 +1,80 @@
{
"id": "night-owl",
"label": "Night Owl",
"order": 7,
"description": "Deep navy palette for night-time coding",
"codeTheme": {
"light": "coy",
"dark": "nightOwl"
},
"shikiTheme": {
"light": "night-owl-light",
"dark": "night-owl"
},
"light": {
"background": "oklch(0.988 0.000 89.9)",
"foreground": "oklch(0.377 0.034 286.9)",
"card": "oklch(0.955 0.000 89.9)",
"cardForeground": "oklch(0.377 0.034 286.9)",
"popover": "oklch(0.955 0.000 89.9)",
"popoverForeground": "oklch(0.377 0.034 286.9)",
"primary": "oklch(0.582 0.156 263.2)",
"primaryForeground": "oklch(0.988 0.000 89.9)",
"secondary": "oklch(0.907 0.000 89.9)",
"secondaryForeground": "oklch(0.377 0.034 286.9)",
"muted": "oklch(0.907 0.000 89.9)",
"mutedForeground": "oklch(0.714 0.030 228.3)",
"accent": "oklch(0.955 0.000 89.9)",
"accentForeground": "oklch(0.377 0.034 286.9)",
"destructive": "oklch(0.601 0.198 26.1)",
"border": "oklch(0.885 0.000 89.9)",
"input": "oklch(0.955 0.000 89.9)",
"ring": "oklch(0.582 0.156 263.2)",
"chart1": "oklch(0.582 0.156 263.2)",
"chart2": "oklch(0.646 0.103 186.6)",
"chart3": "oklch(0.761 0.155 88.2)",
"chart4": "oklch(0.564 0.186 311.7)",
"chart5": "oklch(0.577 0.135 22.2)",
"sidebar": "oklch(0.955 0.000 89.9)",
"sidebarForeground": "oklch(0.377 0.034 286.9)",
"sidebarPrimary": "oklch(0.582 0.156 263.2)",
"sidebarPrimaryForeground": "oklch(0.988 0.000 89.9)",
"sidebarAccent": "oklch(0.907 0.000 89.9)",
"sidebarAccentForeground": "oklch(0.377 0.034 286.9)",
"sidebarBorder": "oklch(0.885 0.000 89.9)",
"sidebarRing": "oklch(0.582 0.156 263.2)"
},
"dark": {
"background": "oklch(0.193 0.045 244.0)",
"foreground": "oklch(0.898 0.020 260.2)",
"card": "oklch(0.273 0.058 247.3)",
"cardForeground": "oklch(0.898 0.020 260.2)",
"popover": "oklch(0.273 0.058 247.3)",
"popoverForeground": "oklch(0.898 0.020 260.2)",
"primary": "oklch(0.744 0.131 264.3)",
"primaryForeground": "oklch(0.193 0.045 244.0)",
"secondary": "oklch(0.341 0.056 244.6)",
"secondaryForeground": "oklch(0.898 0.020 260.2)",
"muted": "oklch(0.341 0.056 244.6)",
"mutedForeground": "oklch(0.553 0.024 196.5)",
"accent": "oklch(0.273 0.058 247.3)",
"accentForeground": "oklch(0.898 0.020 260.2)",
"destructive": "oklch(0.654 0.193 25.1)",
"border": "oklch(0.287 0.051 244.4)",
"input": "oklch(0.273 0.058 247.3)",
"ring": "oklch(0.744 0.131 264.3)",
"chart1": "oklch(0.744 0.131 264.3)",
"chart2": "oklch(0.780 0.204 150.7)",
"chart3": "oklch(0.834 0.153 127.4)",
"chart4": "oklch(0.743 0.135 311.1)",
"chart5": "oklch(0.749 0.138 37.5)",
"sidebar": "oklch(0.193 0.045 244.0)",
"sidebarForeground": "oklch(0.898 0.020 260.2)",
"sidebarPrimary": "oklch(0.744 0.131 264.3)",
"sidebarPrimaryForeground": "oklch(0.193 0.045 244.0)",
"sidebarAccent": "oklch(0.273 0.058 247.3)",
"sidebarAccentForeground": "oklch(0.898 0.020 260.2)",
"sidebarBorder": "oklch(0.273 0.058 247.3)",
"sidebarRing": "oklch(0.744 0.131 264.3)"
}
}
+80
View File
@@ -0,0 +1,80 @@
{
"id": "nord",
"label": "Nord",
"order": 5,
"description": "Arctic, north-bluish color palette from the Polar Night",
"codeTheme": {
"light": "coldarkCold",
"dark": "nord"
},
"shikiTheme": {
"light": "github-light",
"dark": "nord"
},
"light": {
"background": "oklch(0.951 0.007 260.7)",
"foreground": "oklch(0.324 0.023 264.2)",
"card": "oklch(0.933 0.010 261.8)",
"cardForeground": "oklch(0.324 0.023 264.2)",
"popover": "oklch(0.933 0.010 261.8)",
"popoverForeground": "oklch(0.324 0.023 264.2)",
"primary": "oklch(0.594 0.077 254.0)",
"primaryForeground": "oklch(0.951 0.007 260.7)",
"secondary": "oklch(0.899 0.016 262.7)",
"secondaryForeground": "oklch(0.324 0.023 264.2)",
"muted": "oklch(0.899 0.016 262.7)",
"mutedForeground": "oklch(0.452 0.035 264.1)",
"accent": "oklch(0.899 0.016 262.7)",
"accentForeground": "oklch(0.324 0.023 264.2)",
"destructive": "oklch(0.606 0.121 15.3)",
"border": "oklch(0.899 0.016 262.7)",
"input": "oklch(0.933 0.010 261.8)",
"ring": "oklch(0.594 0.077 254.0)",
"chart1": "oklch(0.594 0.077 254.0)",
"chart2": "oklch(0.775 0.062 217.5)",
"chart3": "oklch(0.606 0.121 15.3)",
"chart4": "oklch(0.730 0.070 90.0)",
"chart5": "oklch(0.650 0.100 170.0)",
"sidebar": "oklch(0.933 0.010 261.8)",
"sidebarForeground": "oklch(0.324 0.023 264.2)",
"sidebarPrimary": "oklch(0.594 0.077 254.0)",
"sidebarPrimaryForeground": "oklch(0.951 0.007 260.7)",
"sidebarAccent": "oklch(0.899 0.016 262.7)",
"sidebarAccentForeground": "oklch(0.324 0.023 264.2)",
"sidebarBorder": "oklch(0.899 0.016 262.7)",
"sidebarRing": "oklch(0.594 0.077 254.0)"
},
"dark": {
"background": "oklch(0.324 0.023 264.2)",
"foreground": "oklch(0.899 0.016 262.7)",
"card": "oklch(0.379 0.029 266.5)",
"cardForeground": "oklch(0.899 0.016 262.7)",
"popover": "oklch(0.379 0.029 266.5)",
"popoverForeground": "oklch(0.899 0.016 262.7)",
"primary": "oklch(0.775 0.062 217.5)",
"primaryForeground": "oklch(0.324 0.023 264.2)",
"secondary": "oklch(0.416 0.032 264.1)",
"secondaryForeground": "oklch(0.899 0.016 262.7)",
"muted": "oklch(0.416 0.032 264.1)",
"mutedForeground": "oklch(0.697 0.059 248.7)",
"accent": "oklch(0.416 0.032 264.1)",
"accentForeground": "oklch(0.899 0.016 262.7)",
"destructive": "oklch(0.606 0.121 15.3)",
"border": "oklch(0.379 0.029 266.5)",
"input": "oklch(0.379 0.029 266.5)",
"ring": "oklch(0.775 0.062 217.5)",
"chart1": "oklch(0.775 0.062 217.5)",
"chart2": "oklch(0.697 0.059 248.7)",
"chart3": "oklch(0.606 0.121 15.3)",
"chart4": "oklch(0.770 0.070 90.0)",
"chart5": "oklch(0.700 0.100 170.0)",
"sidebar": "oklch(0.324 0.023 264.2)",
"sidebarForeground": "oklch(0.899 0.016 262.7)",
"sidebarPrimary": "oklch(0.775 0.062 217.5)",
"sidebarPrimaryForeground": "oklch(0.324 0.023 264.2)",
"sidebarAccent": "oklch(0.416 0.032 264.1)",
"sidebarAccentForeground": "oklch(0.899 0.016 262.7)",
"sidebarBorder": "oklch(0.379 0.029 266.5)",
"sidebarRing": "oklch(0.775 0.062 217.5)"
}
}
+80
View File
@@ -0,0 +1,80 @@
{
"id": "poimandres",
"label": "Poimandres",
"order": 8,
"description": "Muted dark palette with soft teal and lavender accents",
"codeTheme": {
"light": "coldarkCold",
"dark": "coldarkDark"
},
"shikiTheme": {
"light": "min-light",
"dark": "poimandres"
},
"light": {
"background": "oklch(0.980 0.005 286.3)",
"foreground": "oklch(0.324 0.023 274.8)",
"card": "oklch(0.950 0.005 286.3)",
"cardForeground": "oklch(0.324 0.023 274.8)",
"popover": "oklch(0.950 0.005 286.3)",
"popoverForeground": "oklch(0.324 0.023 274.8)",
"primary": "oklch(0.528 0.096 248.8)",
"primaryForeground": "oklch(0.980 0.005 286.3)",
"secondary": "oklch(0.899 0.008 286.2)",
"secondaryForeground": "oklch(0.324 0.023 274.8)",
"muted": "oklch(0.899 0.008 286.2)",
"mutedForeground": "oklch(0.593 0.051 276.7)",
"accent": "oklch(0.950 0.005 286.3)",
"accentForeground": "oklch(0.324 0.023 274.8)",
"destructive": "oklch(0.562 0.136 354.3)",
"border": "oklch(0.872 0.011 286.2)",
"input": "oklch(0.950 0.005 286.3)",
"ring": "oklch(0.528 0.096 248.8)",
"chart1": "oklch(0.528 0.096 248.8)",
"chart2": "oklch(0.646 0.103 186.6)",
"chart3": "oklch(0.562 0.136 354.3)",
"chart4": "oklch(0.593 0.051 276.7)",
"chart5": "oklch(0.761 0.155 88.2)",
"sidebar": "oklch(0.950 0.005 286.3)",
"sidebarForeground": "oklch(0.324 0.023 274.8)",
"sidebarPrimary": "oklch(0.528 0.096 248.8)",
"sidebarPrimaryForeground": "oklch(0.980 0.005 286.3)",
"sidebarAccent": "oklch(0.899 0.008 286.2)",
"sidebarAccentForeground": "oklch(0.324 0.023 274.8)",
"sidebarBorder": "oklch(0.872 0.011 286.2)",
"sidebarRing": "oklch(0.528 0.096 248.8)"
},
"dark": {
"background": "oklch(0.237 0.020 271.8)",
"foreground": "oklch(0.751 0.048 277.1)",
"card": "oklch(0.289 0.024 264.1)",
"cardForeground": "oklch(0.751 0.048 277.1)",
"popover": "oklch(0.289 0.024 264.1)",
"popoverForeground": "oklch(0.751 0.048 277.1)",
"primary": "oklch(0.863 0.071 247.4)",
"primaryForeground": "oklch(0.237 0.020 271.8)",
"secondary": "oklch(0.324 0.023 274.8)",
"secondaryForeground": "oklch(0.751 0.048 277.1)",
"muted": "oklch(0.324 0.023 274.8)",
"mutedForeground": "oklch(0.593 0.051 276.7)",
"accent": "oklch(0.289 0.024 264.1)",
"accentForeground": "oklch(0.949 0.020 245.6)",
"destructive": "oklch(0.654 0.146 349.8)",
"border": "oklch(0.324 0.023 274.8)",
"input": "oklch(0.289 0.024 264.1)",
"ring": "oklch(0.863 0.071 247.4)",
"chart1": "oklch(0.863 0.071 247.4)",
"chart2": "oklch(0.837 0.125 176.5)",
"chart3": "oklch(0.944 0.040 323.3)",
"chart4": "oklch(0.756 0.061 247.1)",
"chart5": "oklch(0.654 0.146 349.8)",
"sidebar": "oklch(0.216 0.018 275.0)",
"sidebarForeground": "oklch(0.751 0.048 277.1)",
"sidebarPrimary": "oklch(0.863 0.071 247.4)",
"sidebarPrimaryForeground": "oklch(0.237 0.020 271.8)",
"sidebarAccent": "oklch(0.289 0.024 264.1)",
"sidebarAccentForeground": "oklch(0.949 0.020 245.6)",
"sidebarBorder": "oklch(0.289 0.024 264.1)",
"sidebarRing": "oklch(0.863 0.071 247.4)"
}
}
+80
View File
@@ -0,0 +1,80 @@
{
"id": "rose-pine",
"label": "Rosé Pine",
"order": 2,
"description": "All natural pine, faux fur and a bit of soho vibes",
"codeTheme": {
"light": "duotoneLight",
"dark": "duotoneSea"
},
"shikiTheme": {
"light": "rose-pine-dawn",
"dark": "rose-pine"
},
"light": {
"background": "oklch(0.970 0.011 71.9)",
"foreground": "oklch(0.460 0.063 289.6)",
"card": "oklch(0.987 0.011 76.6)",
"cardForeground": "oklch(0.460 0.063 289.6)",
"popover": "oklch(0.987 0.011 76.6)",
"popoverForeground": "oklch(0.460 0.063 289.6)",
"primary": "oklch(0.696 0.106 23.0)",
"primaryForeground": "oklch(0.970 0.011 71.9)",
"secondary": "oklch(0.939 0.015 64.3)",
"secondaryForeground": "oklch(0.460 0.063 289.6)",
"muted": "oklch(0.939 0.015 64.3)",
"mutedForeground": "oklch(0.673 0.027 298.4)",
"accent": "oklch(0.950 0.010 58.2)",
"accentForeground": "oklch(0.460 0.063 289.6)",
"destructive": "oklch(0.599 0.107 2.7)",
"border": "oklch(0.892 0.006 31.1)",
"input": "oklch(0.892 0.006 31.1)",
"ring": "oklch(0.696 0.106 23.0)",
"chart1": "oklch(0.696 0.106 23.0)",
"chart2": "oklch(0.629 0.066 210.1)",
"chart3": "oklch(0.617 0.074 305.7)",
"chart4": "oklch(0.755 0.146 69.7)",
"chart5": "oklch(0.491 0.077 228.0)",
"sidebar": "oklch(0.970 0.011 71.9)",
"sidebarForeground": "oklch(0.460 0.063 289.6)",
"sidebarPrimary": "oklch(0.696 0.106 23.0)",
"sidebarPrimaryForeground": "oklch(0.970 0.011 71.9)",
"sidebarAccent": "oklch(0.939 0.015 64.3)",
"sidebarAccentForeground": "oklch(0.460 0.063 289.6)",
"sidebarBorder": "oklch(0.892 0.006 31.1)",
"sidebarRing": "oklch(0.696 0.106 23.0)"
},
"dark": {
"background": "oklch(0.213 0.025 291.1)",
"foreground": "oklch(0.909 0.030 290.0)",
"card": "oklch(0.271 0.042 289.2)",
"cardForeground": "oklch(0.909 0.030 290.0)",
"popover": "oklch(0.271 0.042 289.2)",
"popoverForeground": "oklch(0.909 0.030 290.0)",
"primary": "oklch(0.836 0.054 21.1)",
"primaryForeground": "oklch(0.213 0.025 291.1)",
"secondary": "oklch(0.309 0.036 291.0)",
"secondaryForeground": "oklch(0.909 0.030 290.0)",
"muted": "oklch(0.309 0.036 291.0)",
"mutedForeground": "oklch(0.538 0.044 291.4)",
"accent": "oklch(0.251 0.026 287.6)",
"accentForeground": "oklch(0.909 0.030 290.0)",
"destructive": "oklch(0.698 0.156 4.2)",
"border": "oklch(0.372 0.036 291.1)",
"input": "oklch(0.372 0.036 291.1)",
"ring": "oklch(0.836 0.054 21.1)",
"chart1": "oklch(0.836 0.054 21.1)",
"chart2": "oklch(0.822 0.054 209.6)",
"chart3": "oklch(0.776 0.095 305.0)",
"chart4": "oklch(0.843 0.110 74.6)",
"chart5": "oklch(0.528 0.079 227.7)",
"sidebar": "oklch(0.213 0.025 291.1)",
"sidebarForeground": "oklch(0.909 0.030 290.0)",
"sidebarPrimary": "oklch(0.836 0.054 21.1)",
"sidebarPrimaryForeground": "oklch(0.213 0.025 291.1)",
"sidebarAccent": "oklch(0.251 0.026 287.6)",
"sidebarAccentForeground": "oklch(0.909 0.030 290.0)",
"sidebarBorder": "oklch(0.372 0.036 291.1)",
"sidebarRing": "oklch(0.836 0.054 21.1)"
}
}
+80
View File
@@ -0,0 +1,80 @@
{
"id": "synthwave84",
"label": "SynthWave '84",
"order": 11,
"description": "Retro-futuristic neon palette with purple haze",
"codeTheme": {
"light": "prism",
"dark": "synthwave84"
},
"shikiTheme": {
"light": "min-light",
"dark": "synthwave-84"
},
"light": {
"background": "oklch(0.966 0.022 309.6)",
"foreground": "oklch(0.270 0.045 300.7)",
"card": "oklch(0.932 0.027 307.0)",
"cardForeground": "oklch(0.270 0.045 300.7)",
"popover": "oklch(0.932 0.027 307.0)",
"popoverForeground": "oklch(0.270 0.045 300.7)",
"primary": "oklch(0.560 0.183 333.8)",
"primaryForeground": "oklch(0.966 0.022 309.6)",
"secondary": "oklch(0.887 0.037 306.4)",
"secondaryForeground": "oklch(0.270 0.045 300.7)",
"muted": "oklch(0.887 0.037 306.4)",
"mutedForeground": "oklch(0.517 0.069 300.0)",
"accent": "oklch(0.932 0.027 307.0)",
"accentForeground": "oklch(0.270 0.045 300.7)",
"destructive": "oklch(0.555 0.206 18.8)",
"border": "oklch(0.851 0.040 304.9)",
"input": "oklch(0.932 0.027 307.0)",
"ring": "oklch(0.560 0.183 333.8)",
"chart1": "oklch(0.560 0.183 333.8)",
"chart2": "oklch(0.781 0.163 162.1)",
"chart3": "oklch(0.761 0.155 88.2)",
"chart4": "oklch(0.582 0.156 263.2)",
"chart5": "oklch(0.574 0.148 30.4)",
"sidebar": "oklch(0.932 0.027 307.0)",
"sidebarForeground": "oklch(0.270 0.045 300.7)",
"sidebarPrimary": "oklch(0.560 0.183 333.8)",
"sidebarPrimaryForeground": "oklch(0.966 0.022 309.6)",
"sidebarAccent": "oklch(0.887 0.037 306.4)",
"sidebarAccentForeground": "oklch(0.270 0.045 300.7)",
"sidebarBorder": "oklch(0.851 0.040 304.9)",
"sidebarRing": "oklch(0.560 0.183 333.8)"
},
"dark": {
"background": "oklch(0.268 0.033 291.6)",
"foreground": "oklch(0.792 0.000 89.9)",
"card": "oklch(0.270 0.045 300.7)",
"cardForeground": "oklch(0.792 0.000 89.9)",
"popover": "oklch(0.270 0.045 300.7)",
"popoverForeground": "oklch(0.792 0.000 89.9)",
"primary": "oklch(0.769 0.189 339.1)",
"primaryForeground": "oklch(0.268 0.033 291.6)",
"secondary": "oklch(0.313 0.067 295.6)",
"secondaryForeground": "oklch(0.792 0.000 89.9)",
"muted": "oklch(0.313 0.067 295.6)",
"mutedForeground": "oklch(0.650 0.075 277.9)",
"accent": "oklch(0.270 0.045 300.7)",
"accentForeground": "oklch(0.792 0.000 89.9)",
"destructive": "oklch(0.664 0.221 22.4)",
"border": "oklch(0.313 0.067 295.6)",
"input": "oklch(0.270 0.045 300.7)",
"ring": "oklch(0.769 0.189 339.1)",
"chart1": "oklch(0.769 0.189 339.1)",
"chart2": "oklch(0.892 0.144 193.2)",
"chart3": "oklch(0.904 0.150 95.3)",
"chart4": "oklch(0.697 0.152 302.3)",
"chart5": "oklch(0.731 0.153 27.2)",
"sidebar": "oklch(0.233 0.038 292.3)",
"sidebarForeground": "oklch(0.792 0.000 89.9)",
"sidebarPrimary": "oklch(0.769 0.189 339.1)",
"sidebarPrimaryForeground": "oklch(0.268 0.033 291.6)",
"sidebarAccent": "oklch(0.270 0.045 300.7)",
"sidebarAccentForeground": "oklch(0.792 0.000 89.9)",
"sidebarBorder": "oklch(0.270 0.045 300.7)",
"sidebarRing": "oklch(0.769 0.189 339.1)"
}
}
+80
View File
@@ -0,0 +1,80 @@
{
"id": "tokyo-night",
"label": "Tokyo Night",
"order": 3,
"description": "Neon-lit cityscape with deep blue undertones",
"codeTheme": {
"light": "materialLight",
"dark": "materialOceanic"
},
"shikiTheme": {
"light": "github-light",
"dark": "tokyo-night"
},
"light": {
"background": "oklch(0.929 0.008 278.6)",
"foreground": "oklch(0.360 0.053 273.4)",
"card": "oklch(0.895 0.010 273.3)",
"cardForeground": "oklch(0.360 0.053 273.4)",
"popover": "oklch(0.895 0.010 273.3)",
"popoverForeground": "oklch(0.360 0.053 273.4)",
"primary": "oklch(0.476 0.140 260.2)",
"primaryForeground": "oklch(1.000 0.000 89.9)",
"secondary": "oklch(0.883 0.010 273.3)",
"secondaryForeground": "oklch(0.358 0.031 269.6)",
"muted": "oklch(0.883 0.010 273.3)",
"mutedForeground": "oklch(0.555 0.022 279.2)",
"accent": "oklch(0.914 0.008 278.6)",
"accentForeground": "oklch(0.358 0.031 269.6)",
"destructive": "oklch(0.480 0.100 9.5)",
"border": "oklch(0.815 0.007 277.1)",
"input": "oklch(0.883 0.010 273.3)",
"ring": "oklch(0.476 0.140 260.2)",
"chart1": "oklch(0.476 0.140 260.2)",
"chart2": "oklch(0.700 0.040 275.4)",
"chart3": "oklch(0.580 0.110 165.0)",
"chart4": "oklch(0.550 0.120 310.0)",
"chart5": "oklch(0.600 0.130 20.0)",
"sidebar": "oklch(0.883 0.010 273.3)",
"sidebarForeground": "oklch(0.358 0.031 269.6)",
"sidebarPrimary": "oklch(0.476 0.140 260.2)",
"sidebarPrimaryForeground": "oklch(1.000 0.000 89.9)",
"sidebarAccent": "oklch(0.914 0.008 278.6)",
"sidebarAccentForeground": "oklch(0.358 0.031 269.6)",
"sidebarBorder": "oklch(0.815 0.007 277.1)",
"sidebarRing": "oklch(0.476 0.140 260.2)"
},
"dark": {
"background": "oklch(0.226 0.021 280.5)",
"foreground": "oklch(0.767 0.054 275.5)",
"card": "oklch(0.261 0.034 274.2)",
"cardForeground": "oklch(0.767 0.054 275.5)",
"popover": "oklch(0.261 0.034 274.2)",
"popoverForeground": "oklch(0.767 0.054 275.5)",
"primary": "oklch(0.719 0.132 264.2)",
"primaryForeground": "oklch(0.226 0.021 280.5)",
"secondary": "oklch(0.282 0.036 274.7)",
"secondaryForeground": "oklch(0.846 0.061 274.8)",
"muted": "oklch(0.306 0.037 273.2)",
"mutedForeground": "oklch(0.496 0.068 274.4)",
"accent": "oklch(0.259 0.025 274.4)",
"accentForeground": "oklch(0.846 0.061 274.8)",
"destructive": "oklch(0.723 0.159 10.3)",
"border": "oklch(0.266 0.028 281.3)",
"input": "oklch(0.240 0.031 275.6)",
"ring": "oklch(0.719 0.132 264.2)",
"chart1": "oklch(0.719 0.132 264.2)",
"chart2": "oklch(0.700 0.120 85.0)",
"chart3": "oklch(0.630 0.120 165.0)",
"chart4": "oklch(0.650 0.120 310.0)",
"chart5": "oklch(0.723 0.159 10.3)",
"sidebar": "oklch(0.204 0.016 284.9)",
"sidebarForeground": "oklch(0.767 0.054 275.5)",
"sidebarPrimary": "oklch(0.719 0.132 264.2)",
"sidebarPrimaryForeground": "oklch(0.226 0.021 280.5)",
"sidebarAccent": "oklch(0.259 0.025 274.4)",
"sidebarAccentForeground": "oklch(0.846 0.061 274.8)",
"sidebarBorder": "oklch(0.175 0.008 285.5)",
"sidebarRing": "oklch(0.719 0.132 264.2)"
}
}
+80
View File
@@ -0,0 +1,80 @@
{
"id": "vesper",
"label": "Vesper",
"order": 9,
"description": "Warm monochrome palette with amber highlights",
"codeTheme": {
"light": "gruvboxLight",
"dark": "twilight"
},
"shikiTheme": {
"light": "min-light",
"dark": "vesper"
},
"light": {
"background": "oklch(0.980 0.005 78.3)",
"foreground": "oklch(0.344 0.007 48.5)",
"card": "oklch(0.945 0.009 78.3)",
"cardForeground": "oklch(0.344 0.007 48.5)",
"popover": "oklch(0.945 0.009 78.3)",
"popoverForeground": "oklch(0.344 0.007 48.5)",
"primary": "oklch(0.638 0.120 59.5)",
"primaryForeground": "oklch(0.980 0.005 78.3)",
"secondary": "oklch(0.909 0.015 80.7)",
"secondaryForeground": "oklch(0.344 0.007 48.5)",
"muted": "oklch(0.909 0.015 80.7)",
"mutedForeground": "oklch(0.617 0.009 56.2)",
"accent": "oklch(0.945 0.009 78.3)",
"accentForeground": "oklch(0.344 0.007 48.5)",
"destructive": "oklch(0.574 0.148 30.4)",
"border": "oklch(0.878 0.015 80.7)",
"input": "oklch(0.945 0.009 78.3)",
"ring": "oklch(0.638 0.120 59.5)",
"chart1": "oklch(0.638 0.120 59.5)",
"chart2": "oklch(0.646 0.103 186.6)",
"chart3": "oklch(0.574 0.148 30.4)",
"chart4": "oklch(0.564 0.186 311.7)",
"chart5": "oklch(0.582 0.156 263.2)",
"sidebar": "oklch(0.945 0.009 78.3)",
"sidebarForeground": "oklch(0.344 0.007 48.5)",
"sidebarPrimary": "oklch(0.638 0.120 59.5)",
"sidebarPrimaryForeground": "oklch(0.980 0.005 78.3)",
"sidebarAccent": "oklch(0.909 0.015 80.7)",
"sidebarAccentForeground": "oklch(0.344 0.007 48.5)",
"sidebarBorder": "oklch(0.878 0.015 80.7)",
"sidebarRing": "oklch(0.638 0.120 59.5)"
},
"dark": {
"background": "oklch(0.173 0.000 89.9)",
"foreground": "oklch(0.779 0.000 89.9)",
"card": "oklch(0.226 0.000 89.9)",
"cardForeground": "oklch(0.779 0.000 89.9)",
"popover": "oklch(0.226 0.000 89.9)",
"popoverForeground": "oklch(0.779 0.000 89.9)",
"primary": "oklch(0.869 0.088 60.7)",
"primaryForeground": "oklch(0.173 0.000 89.9)",
"secondary": "oklch(0.277 0.000 89.9)",
"secondaryForeground": "oklch(0.779 0.000 89.9)",
"muted": "oklch(0.277 0.000 89.9)",
"mutedForeground": "oklch(0.528 0.000 89.9)",
"accent": "oklch(0.226 0.000 89.9)",
"accentForeground": "oklch(0.779 0.000 89.9)",
"destructive": "oklch(0.788 0.103 31.3)",
"border": "oklch(0.277 0.000 89.9)",
"input": "oklch(0.226 0.000 89.9)",
"ring": "oklch(0.869 0.088 60.7)",
"chart1": "oklch(0.869 0.088 60.7)",
"chart2": "oklch(0.930 0.103 175.1)",
"chart3": "oklch(0.783 0.111 278.4)",
"chart4": "oklch(0.833 0.093 19.2)",
"chart5": "oklch(0.832 0.092 293.3)",
"sidebar": "oklch(0.145 0.000 89.9)",
"sidebarForeground": "oklch(0.779 0.000 89.9)",
"sidebarPrimary": "oklch(0.869 0.088 60.7)",
"sidebarPrimaryForeground": "oklch(0.173 0.000 89.9)",
"sidebarAccent": "oklch(0.226 0.000 89.9)",
"sidebarAccentForeground": "oklch(0.779 0.000 89.9)",
"sidebarBorder": "oklch(0.226 0.000 89.9)",
"sidebarRing": "oklch(0.869 0.088 60.7)"
}
}