feat: implement comprehensive theming system

- Add ThemeContext with support for dark, gray, light, and custom themes
- Create theme switching UI in Settings with theme selector
- Add custom color editor for custom theme mode
- Update styles.css with theme-specific CSS variables
- Add theme storage API methods for persistence
- Update syntax highlighting to match selected theme
- Wrap App with ThemeProvider for global theme access

The theming system allows users to switch between predefined themes
or create their own custom theme with live color editing.
This commit is contained in:
Mufeed VH
2025-07-28 15:05:46 +05:30
parent 4ddb6a1995
commit c87d36e118
10 changed files with 809 additions and 192 deletions

View File

@@ -4,6 +4,7 @@ import { Plus, Loader2, Bot, FolderCode } from "lucide-react";
import { api, type Project, type Session, type ClaudeMdFile } from "@/lib/api"; import { api, type Project, type Session, type ClaudeMdFile } from "@/lib/api";
import { OutputCacheProvider } from "@/lib/outputCache"; import { OutputCacheProvider } from "@/lib/outputCache";
import { TabProvider } from "@/contexts/TabContext"; import { TabProvider } from "@/contexts/TabContext";
import { ThemeProvider } from "@/contexts/ThemeContext";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Card } from "@/components/ui/card"; import { Card } from "@/components/ui/card";
import { ProjectList } from "@/components/ProjectList"; import { ProjectList } from "@/components/ProjectList";
@@ -508,11 +509,13 @@ function AppContent() {
*/ */
function App() { function App() {
return ( return (
<OutputCacheProvider> <ThemeProvider>
<TabProvider> <OutputCacheProvider>
<AppContent /> <TabProvider>
</TabProvider> <AppContent />
</OutputCacheProvider> </TabProvider>
</OutputCacheProvider>
</ThemeProvider>
); );
} }

View File

@@ -14,6 +14,7 @@ import { Label } from "@/components/ui/label";
import { Switch } from "@/components/ui/switch"; import { Switch } from "@/components/ui/switch";
import { Card } from "@/components/ui/card"; import { Card } from "@/components/ui/card";
import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs"; import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { import {
api, api,
type ClaudeSettings, type ClaudeSettings,
@@ -25,6 +26,7 @@ import { ClaudeVersionSelector } from "./ClaudeVersionSelector";
import { StorageTab } from "./StorageTab"; import { StorageTab } from "./StorageTab";
import { HooksEditor } from "./HooksEditor"; import { HooksEditor } from "./HooksEditor";
import { SlashCommandsManager } from "./SlashCommandsManager"; import { SlashCommandsManager } from "./SlashCommandsManager";
import { useTheme } from "@/hooks";
interface SettingsProps { interface SettingsProps {
/** /**
@@ -77,6 +79,9 @@ export const Settings: React.FC<SettingsProps> = ({
const [userHooksChanged, setUserHooksChanged] = useState(false); const [userHooksChanged, setUserHooksChanged] = useState(false);
const getUserHooks = React.useRef<(() => any) | null>(null); const getUserHooks = React.useRef<(() => any) | null>(null);
// Theme hook
const { theme, setTheme, customColors, setCustomColors } = useTheme();
// Load settings on mount // Load settings on mount
useEffect(() => { useEffect(() => {
loadSettings(); loadSettings();
@@ -375,6 +380,155 @@ export const Settings: React.FC<SettingsProps> = ({
<h3 className="text-base font-semibold mb-4">General Settings</h3> <h3 className="text-base font-semibold mb-4">General Settings</h3>
<div className="space-y-4"> <div className="space-y-4">
{/* Theme Selector */}
<div className="space-y-2">
<Label htmlFor="theme">Theme</Label>
<Select
value={theme}
onValueChange={(value) => setTheme(value as any)}
>
<SelectTrigger id="theme" className="w-full">
<SelectValue placeholder="Select a theme" />
</SelectTrigger>
<SelectContent>
<SelectItem value="dark">Dark</SelectItem>
<SelectItem value="gray">Gray</SelectItem>
<SelectItem value="light">Light</SelectItem>
<SelectItem value="custom">Custom</SelectItem>
</SelectContent>
</Select>
<p className="text-xs text-muted-foreground">
Choose your preferred color theme for the interface
</p>
</div>
{/* Custom Color Editor */}
{theme === 'custom' && (
<div className="space-y-4 p-4 border rounded-lg bg-muted/20">
<h4 className="text-sm font-medium">Custom Theme Colors</h4>
<div className="grid grid-cols-2 gap-4">
{/* Background Color */}
<div className="space-y-2">
<Label htmlFor="color-background" className="text-xs">Background</Label>
<div className="flex gap-2">
<Input
id="color-background"
type="text"
value={customColors.background}
onChange={(e) => setCustomColors({ background: e.target.value })}
placeholder="oklch(0.12 0.01 240)"
className="font-mono text-xs"
/>
<div
className="w-10 h-10 rounded border"
style={{ backgroundColor: customColors.background }}
/>
</div>
</div>
{/* Foreground Color */}
<div className="space-y-2">
<Label htmlFor="color-foreground" className="text-xs">Foreground</Label>
<div className="flex gap-2">
<Input
id="color-foreground"
type="text"
value={customColors.foreground}
onChange={(e) => setCustomColors({ foreground: e.target.value })}
placeholder="oklch(0.98 0.01 240)"
className="font-mono text-xs"
/>
<div
className="w-10 h-10 rounded border"
style={{ backgroundColor: customColors.foreground }}
/>
</div>
</div>
{/* Primary Color */}
<div className="space-y-2">
<Label htmlFor="color-primary" className="text-xs">Primary</Label>
<div className="flex gap-2">
<Input
id="color-primary"
type="text"
value={customColors.primary}
onChange={(e) => setCustomColors({ primary: e.target.value })}
placeholder="oklch(0.98 0.01 240)"
className="font-mono text-xs"
/>
<div
className="w-10 h-10 rounded border"
style={{ backgroundColor: customColors.primary }}
/>
</div>
</div>
{/* Card Color */}
<div className="space-y-2">
<Label htmlFor="color-card" className="text-xs">Card</Label>
<div className="flex gap-2">
<Input
id="color-card"
type="text"
value={customColors.card}
onChange={(e) => setCustomColors({ card: e.target.value })}
placeholder="oklch(0.14 0.01 240)"
className="font-mono text-xs"
/>
<div
className="w-10 h-10 rounded border"
style={{ backgroundColor: customColors.card }}
/>
</div>
</div>
{/* Accent Color */}
<div className="space-y-2">
<Label htmlFor="color-accent" className="text-xs">Accent</Label>
<div className="flex gap-2">
<Input
id="color-accent"
type="text"
value={customColors.accent}
onChange={(e) => setCustomColors({ accent: e.target.value })}
placeholder="oklch(0.16 0.01 240)"
className="font-mono text-xs"
/>
<div
className="w-10 h-10 rounded border"
style={{ backgroundColor: customColors.accent }}
/>
</div>
</div>
{/* Destructive Color */}
<div className="space-y-2">
<Label htmlFor="color-destructive" className="text-xs">Destructive</Label>
<div className="flex gap-2">
<Input
id="color-destructive"
type="text"
value={customColors.destructive}
onChange={(e) => setCustomColors({ destructive: e.target.value })}
placeholder="oklch(0.6 0.2 25)"
className="font-mono text-xs"
/>
<div
className="w-10 h-10 rounded border"
style={{ backgroundColor: customColors.destructive }}
/>
</div>
</div>
</div>
<p className="text-xs text-muted-foreground">
Use CSS color values (hex, rgb, oklch, etc.). Changes apply immediately.
</p>
</div>
)}
{/* Include Co-authored By */} {/* Include Co-authored By */}
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div className="space-y-0.5 flex-1"> <div className="space-y-0.5 flex-1">

View File

@@ -11,7 +11,8 @@ import { cn } from "@/lib/utils";
import ReactMarkdown from "react-markdown"; import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm"; import remarkGfm from "remark-gfm";
import { Prism as SyntaxHighlighter } from "react-syntax-highlighter"; import { Prism as SyntaxHighlighter } from "react-syntax-highlighter";
import { claudeSyntaxTheme } from "@/lib/claudeSyntaxTheme"; import { getClaudeSyntaxTheme } from "@/lib/claudeSyntaxTheme";
import { useTheme } from "@/hooks";
import type { ClaudeStreamMessage } from "./AgentExecution"; import type { ClaudeStreamMessage } from "./AgentExecution";
import { import {
TodoWidget, TodoWidget,
@@ -54,6 +55,10 @@ const StreamMessageComponent: React.FC<StreamMessageProps> = ({ message, classNa
// State to track tool results mapped by tool call ID // State to track tool results mapped by tool call ID
const [toolResults, setToolResults] = useState<Map<string, any>>(new Map()); const [toolResults, setToolResults] = useState<Map<string, any>>(new Map());
// Get current theme
const { theme } = useTheme();
const syntaxTheme = getClaudeSyntaxTheme(theme);
// Extract all tool results from stream messages // Extract all tool results from stream messages
useEffect(() => { useEffect(() => {
const results = new Map<string, any>(); const results = new Map<string, any>();
@@ -131,7 +136,7 @@ const StreamMessageComponent: React.FC<StreamMessageProps> = ({ message, classNa
const match = /language-(\w+)/.exec(className || ''); const match = /language-(\w+)/.exec(className || '');
return !inline && match ? ( return !inline && match ? (
<SyntaxHighlighter <SyntaxHighlighter
style={claudeSyntaxTheme} style={syntaxTheme}
language={match[1]} language={match[1]}
PreTag="div" PreTag="div"
{...props} {...props}
@@ -660,7 +665,7 @@ const StreamMessageComponent: React.FC<StreamMessageProps> = ({ message, classNa
const match = /language-(\w+)/.exec(className || ''); const match = /language-(\w+)/.exec(className || '');
return !inline && match ? ( return !inline && match ? (
<SyntaxHighlighter <SyntaxHighlighter
style={claudeSyntaxTheme} style={syntaxTheme}
language={match[1]} language={match[1]}
PreTag="div" PreTag="div"
{...props} {...props}

View File

@@ -51,7 +51,8 @@ import {
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { Prism as SyntaxHighlighter } from "react-syntax-highlighter"; import { Prism as SyntaxHighlighter } from "react-syntax-highlighter";
import { claudeSyntaxTheme } from "@/lib/claudeSyntaxTheme"; import { getClaudeSyntaxTheme } from "@/lib/claudeSyntaxTheme";
import { useTheme } from "@/hooks";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { createPortal } from "react-dom"; import { createPortal } from "react-dom";
import * as Diff from 'diff'; import * as Diff from 'diff';
@@ -400,6 +401,8 @@ export const ReadWidget: React.FC<{ filePath: string; result?: any }> = ({ fileP
*/ */
export const ReadResultWidget: React.FC<{ content: string; filePath?: string }> = ({ content, filePath }) => { export const ReadResultWidget: React.FC<{ content: string; filePath?: string }> = ({ content, filePath }) => {
const [isExpanded, setIsExpanded] = useState(false); const [isExpanded, setIsExpanded] = useState(false);
const { theme } = useTheme();
const syntaxTheme = getClaudeSyntaxTheme(theme);
// Extract file extension for syntax highlighting // Extract file extension for syntax highlighting
const getLanguage = (path?: string) => { const getLanguage = (path?: string) => {
@@ -530,7 +533,7 @@ export const ReadResultWidget: React.FC<{ content: string; filePath?: string }>
<div className="relative overflow-x-auto"> <div className="relative overflow-x-auto">
<SyntaxHighlighter <SyntaxHighlighter
language={language} language={language}
style={claudeSyntaxTheme} style={syntaxTheme}
showLineNumbers showLineNumbers
startingLineNumber={startLineNumber} startingLineNumber={startLineNumber}
wrapLongLines={false} wrapLongLines={false}
@@ -629,6 +632,9 @@ export const BashWidget: React.FC<{
description?: string; description?: string;
result?: any; result?: any;
}> = ({ command, description, result }) => { }> = ({ command, description, result }) => {
const { theme } = useTheme();
const syntaxTheme = getClaudeSyntaxTheme(theme);
// Extract result content if available // Extract result content if available
let resultContent = ''; let resultContent = '';
let isError = false; let isError = false;
@@ -695,6 +701,8 @@ export const BashWidget: React.FC<{
*/ */
export const WriteWidget: React.FC<{ filePath: string; content: string; result?: any }> = ({ filePath, content, result: _result }) => { export const WriteWidget: React.FC<{ filePath: string; content: string; result?: any }> = ({ filePath, content, result: _result }) => {
const [isMaximized, setIsMaximized] = useState(false); const [isMaximized, setIsMaximized] = useState(false);
const { theme } = useTheme();
const syntaxTheme = getClaudeSyntaxTheme(theme);
// Extract file extension for syntax highlighting // Extract file extension for syntax highlighting
const getLanguage = (path: string) => { const getLanguage = (path: string) => {
@@ -776,7 +784,7 @@ export const WriteWidget: React.FC<{ filePath: string; content: string; result?:
<div className="flex-1 overflow-auto"> <div className="flex-1 overflow-auto">
<SyntaxHighlighter <SyntaxHighlighter
language={language} language={language}
style={claudeSyntaxTheme} style={syntaxTheme}
customStyle={{ customStyle={{
margin: 0, margin: 0,
padding: '1.5rem', padding: '1.5rem',
@@ -827,7 +835,7 @@ export const WriteWidget: React.FC<{ filePath: string; content: string; result?:
<div className="overflow-auto flex-1"> <div className="overflow-auto flex-1">
<SyntaxHighlighter <SyntaxHighlighter
language={language} language={language}
style={claudeSyntaxTheme} style={syntaxTheme}
customStyle={{ customStyle={{
margin: 0, margin: 0,
padding: '1rem', padding: '1rem',
@@ -1121,6 +1129,8 @@ export const EditWidget: React.FC<{
new_string: string; new_string: string;
result?: any; result?: any;
}> = ({ file_path, old_string, new_string, result: _result }) => { }> = ({ file_path, old_string, new_string, result: _result }) => {
const { theme } = useTheme();
const syntaxTheme = getClaudeSyntaxTheme(theme);
const diffResult = Diff.diffLines(old_string || '', new_string || '', { const diffResult = Diff.diffLines(old_string || '', new_string || '', {
newlineIsToken: true, newlineIsToken: true,
@@ -1165,7 +1175,7 @@ export const EditWidget: React.FC<{
<div className="flex-1"> <div className="flex-1">
<SyntaxHighlighter <SyntaxHighlighter
language={language} language={language}
style={claudeSyntaxTheme} style={syntaxTheme}
PreTag="div" PreTag="div"
wrapLongLines={false} wrapLongLines={false}
customStyle={{ customStyle={{
@@ -1196,6 +1206,9 @@ export const EditWidget: React.FC<{
* Widget for Edit tool result - shows a diff view * Widget for Edit tool result - shows a diff view
*/ */
export const EditResultWidget: React.FC<{ content: string }> = ({ content }) => { export const EditResultWidget: React.FC<{ content: string }> = ({ content }) => {
const { theme } = useTheme();
const syntaxTheme = getClaudeSyntaxTheme(theme);
// Parse the content to extract file path and code snippet // Parse the content to extract file path and code snippet
const lines = content.split('\n'); const lines = content.split('\n');
let filePath = ''; let filePath = '';
@@ -1245,7 +1258,7 @@ export const EditResultWidget: React.FC<{ content: string }> = ({ content }) =>
<div className="overflow-x-auto max-h-[440px]"> <div className="overflow-x-auto max-h-[440px]">
<SyntaxHighlighter <SyntaxHighlighter
language={language} language={language}
style={claudeSyntaxTheme} style={syntaxTheme}
showLineNumbers showLineNumbers
startingLineNumber={startLineNumber} startingLineNumber={startLineNumber}
wrapLongLines={false} wrapLongLines={false}
@@ -1282,6 +1295,8 @@ export const MCPWidget: React.FC<{
result?: any; result?: any;
}> = ({ toolName, input, result: _result }) => { }> = ({ toolName, input, result: _result }) => {
const [isExpanded, setIsExpanded] = useState(false); const [isExpanded, setIsExpanded] = useState(false);
const { theme } = useTheme();
const syntaxTheme = getClaudeSyntaxTheme(theme);
// Parse the tool name to extract components // Parse the tool name to extract components
// Format: mcp__namespace__method // Format: mcp__namespace__method
@@ -1396,7 +1411,7 @@ export const MCPWidget: React.FC<{
)}> )}>
<SyntaxHighlighter <SyntaxHighlighter
language="json" language="json"
style={claudeSyntaxTheme} style={syntaxTheme}
customStyle={{ customStyle={{
margin: 0, margin: 0,
padding: '0.75rem', padding: '0.75rem',
@@ -1585,6 +1600,8 @@ export const MultiEditWidget: React.FC<{
}> = ({ file_path, edits, result: _result }) => { }> = ({ file_path, edits, result: _result }) => {
const [isExpanded, setIsExpanded] = useState(false); const [isExpanded, setIsExpanded] = useState(false);
const language = getLanguage(file_path); const language = getLanguage(file_path);
const { theme } = useTheme();
const syntaxTheme = getClaudeSyntaxTheme(theme);
return ( return (
<div className="space-y-2"> <div className="space-y-2">
@@ -1645,7 +1662,7 @@ export const MultiEditWidget: React.FC<{
<div className="flex-1"> <div className="flex-1">
<SyntaxHighlighter <SyntaxHighlighter
language={language} language={language}
style={claudeSyntaxTheme} style={syntaxTheme}
PreTag="div" PreTag="div"
wrapLongLines={false} wrapLongLines={false}
customStyle={{ customStyle={{

View File

@@ -0,0 +1,182 @@
import React, { createContext, useState, useContext, useCallback, useEffect } from 'react';
import { api } from '../lib/api';
export type ThemeMode = 'dark' | 'gray' | 'light' | 'custom';
export interface CustomThemeColors {
background: string;
foreground: string;
card: string;
cardForeground: string;
primary: string;
primaryForeground: string;
secondary: string;
secondaryForeground: string;
muted: string;
mutedForeground: string;
accent: string;
accentForeground: string;
destructive: string;
destructiveForeground: string;
border: string;
input: string;
ring: string;
}
interface ThemeContextType {
theme: ThemeMode;
customColors: CustomThemeColors;
setTheme: (theme: ThemeMode) => Promise<void>;
setCustomColors: (colors: Partial<CustomThemeColors>) => Promise<void>;
isLoading: boolean;
}
const ThemeContext = createContext<ThemeContextType | undefined>(undefined);
const THEME_STORAGE_KEY = 'theme_preference';
const CUSTOM_COLORS_STORAGE_KEY = 'theme_custom_colors';
// Default custom theme colors (based on current dark theme)
const DEFAULT_CUSTOM_COLORS: CustomThemeColors = {
background: 'oklch(0.12 0.01 240)',
foreground: 'oklch(0.98 0.01 240)',
card: 'oklch(0.14 0.01 240)',
cardForeground: 'oklch(0.98 0.01 240)',
primary: 'oklch(0.98 0.01 240)',
primaryForeground: 'oklch(0.12 0.01 240)',
secondary: 'oklch(0.16 0.01 240)',
secondaryForeground: 'oklch(0.98 0.01 240)',
muted: 'oklch(0.16 0.01 240)',
mutedForeground: 'oklch(0.65 0.01 240)',
accent: 'oklch(0.16 0.01 240)',
accentForeground: 'oklch(0.98 0.01 240)',
destructive: 'oklch(0.6 0.2 25)',
destructiveForeground: 'oklch(0.98 0.01 240)',
border: 'oklch(0.16 0.01 240)',
input: 'oklch(0.16 0.01 240)',
ring: 'oklch(0.98 0.01 240)',
};
export const ThemeProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
const [theme, setThemeState] = useState<ThemeMode>('dark');
const [customColors, setCustomColorsState] = useState<CustomThemeColors>(DEFAULT_CUSTOM_COLORS);
const [isLoading, setIsLoading] = useState(true);
// Load theme preference and custom colors from storage
useEffect(() => {
const loadTheme = async () => {
try {
// Load theme preference
const savedTheme = await api.getSetting(THEME_STORAGE_KEY);
if (savedTheme) {
const themeMode = savedTheme as ThemeMode;
setThemeState(themeMode);
applyTheme(themeMode, customColors);
}
// Load custom colors
const savedColors = await api.getSetting(CUSTOM_COLORS_STORAGE_KEY);
if (savedColors) {
const colors = JSON.parse(savedColors) as CustomThemeColors;
setCustomColorsState(colors);
if (theme === 'custom') {
applyTheme('custom', colors);
}
}
} catch (error) {
console.error('Failed to load theme settings:', error);
} finally {
setIsLoading(false);
}
};
loadTheme();
}, []);
// Apply theme to document
const applyTheme = useCallback((themeMode: ThemeMode, colors: CustomThemeColors) => {
const root = document.documentElement;
// Remove all theme classes
root.classList.remove('theme-dark', 'theme-gray', 'theme-light', 'theme-custom');
// Add new theme class
root.classList.add(`theme-${themeMode}`);
// If custom theme, apply custom colors as CSS variables
if (themeMode === 'custom') {
Object.entries(colors).forEach(([key, value]) => {
const cssVarName = `--color-${key.replace(/([A-Z])/g, '-$1').toLowerCase()}`;
root.style.setProperty(cssVarName, value);
});
} else {
// Clear custom CSS variables when not using custom theme
Object.keys(colors).forEach((key) => {
const cssVarName = `--color-${key.replace(/([A-Z])/g, '-$1').toLowerCase()}`;
root.style.removeProperty(cssVarName);
});
}
}, []);
const setTheme = useCallback(async (newTheme: ThemeMode) => {
try {
setIsLoading(true);
// Apply theme immediately
setThemeState(newTheme);
applyTheme(newTheme, customColors);
// Save to storage
await api.saveSetting(THEME_STORAGE_KEY, newTheme);
} catch (error) {
console.error('Failed to save theme preference:', error);
} finally {
setIsLoading(false);
}
}, [customColors, applyTheme]);
const setCustomColors = useCallback(async (colors: Partial<CustomThemeColors>) => {
try {
setIsLoading(true);
const newColors = { ...customColors, ...colors };
setCustomColorsState(newColors);
// Apply immediately if custom theme is active
if (theme === 'custom') {
applyTheme('custom', newColors);
}
// Save to storage
await api.saveSetting(CUSTOM_COLORS_STORAGE_KEY, JSON.stringify(newColors));
} catch (error) {
console.error('Failed to save custom colors:', error);
} finally {
setIsLoading(false);
}
}, [theme, customColors, applyTheme]);
const value: ThemeContextType = {
theme,
customColors,
setTheme,
setCustomColors,
isLoading,
};
return (
<ThemeContext.Provider value={value}>
{children}
</ThemeContext.Provider>
);
};
export const useThemeContext = () => {
const context = useContext(ThemeContext);
if (!context) {
throw new Error('useThemeContext must be used within a ThemeProvider');
}
return context;
};

View File

@@ -2,4 +2,5 @@
export { useLoadingState } from './useLoadingState'; export { useLoadingState } from './useLoadingState';
export { useDebounce, useDebouncedCallback } from './useDebounce'; export { useDebounce, useDebouncedCallback } from './useDebounce';
export { useApiCall } from './useApiCall'; export { useApiCall } from './useApiCall';
export { usePagination } from './usePagination'; export { usePagination } from './usePagination';
export { useTheme } from './useTheme';

24
src/hooks/useTheme.ts Normal file
View File

@@ -0,0 +1,24 @@
import { useThemeContext } from '../contexts/ThemeContext';
/**
* Hook to access and control the theme system
*
* @returns {Object} Theme utilities and state
* @returns {ThemeMode} theme - Current theme mode ('dark' | 'gray' | 'light' | 'custom')
* @returns {CustomThemeColors} customColors - Custom theme color configuration
* @returns {Function} setTheme - Function to change the theme mode
* @returns {Function} setCustomColors - Function to update custom theme colors
* @returns {boolean} isLoading - Whether theme operations are in progress
*
* @example
* const { theme, setTheme } = useTheme();
*
* // Change theme
* await setTheme('light');
*
* // Update custom colors
* await setCustomColors({ background: 'oklch(0.98 0.01 240)' });
*/
export const useTheme = () => {
return useThemeContext();
};

View File

@@ -1685,6 +1685,50 @@ export const api = {
} }
}, },
// Theme settings helpers
/**
* Gets a setting from the app_settings table
* @param key - The setting key to retrieve
* @returns Promise resolving to the setting value or null if not found
*/
async getSetting(key: string): Promise<string | null> {
try {
// Use storageReadTable to safely query the app_settings table
const result = await this.storageReadTable('app_settings', 1, 1000);
const setting = result?.data?.find((row: any) => row.key === key);
return setting?.value || null;
} catch (error) {
console.error(`Failed to get setting ${key}:`, error);
return null;
}
},
/**
* Saves a setting to the app_settings table (insert or update)
* @param key - The setting key
* @param value - The setting value
* @returns Promise resolving when the setting is saved
*/
async saveSetting(key: string, value: string): Promise<void> {
try {
// Try to update first
try {
await this.storageUpdateRow(
'app_settings',
{ key },
{ value }
);
} catch (updateError) {
// If update fails (row doesn't exist), insert new row
await this.storageInsertRow('app_settings', { key, value });
}
} catch (error) {
console.error(`Failed to save setting ${key}:`, error);
throw error;
}
},
/** /**
* Get hooks configuration for a specific scope * Get hooks configuration for a specific scope
* @param scope - The configuration scope: 'user', 'project', or 'local' * @param scope - The configuration scope: 'user', 'project', or 'local'

View File

@@ -1,175 +1,258 @@
import { ThemeMode } from '@/contexts/ThemeContext';
/** /**
* Claude-themed syntax highlighting theme * Claude-themed syntax highlighting theme factory
* Features orange, purple, and violet colors to match Claude's aesthetic * Returns different syntax themes based on the current theme mode
*
* @param theme - The current theme mode
* @returns Prism syntax highlighting theme object
*/ */
export const claudeSyntaxTheme: any = { export const getClaudeSyntaxTheme = (theme: ThemeMode): any => {
'code[class*="language-"]': { const themes = {
color: '#e3e8f0', dark: {
background: 'transparent', base: '#e3e8f0',
textShadow: 'none', background: 'transparent',
fontFamily: 'var(--font-mono)', comment: '#6b7280',
fontSize: '0.875em', punctuation: '#9ca3af',
textAlign: 'left', property: '#f59e0b', // Amber/Orange
whiteSpace: 'pre', tag: '#8b5cf6', // Violet
wordSpacing: 'normal', string: '#10b981', // Emerald Green
wordBreak: 'normal', function: '#818cf8', // Indigo
wordWrap: 'normal', keyword: '#c084fc', // Light Violet
lineHeight: '1.5', variable: '#a78bfa', // Light Purple
MozTabSize: '4', operator: '#9ca3af',
OTabSize: '4', },
tabSize: '4', gray: {
WebkitHyphens: 'none', base: '#e3e8f0',
MozHyphens: 'none', background: 'transparent',
msHyphens: 'none', comment: '#71717a',
hyphens: 'none', punctuation: '#a1a1aa',
}, property: '#fbbf24', // Yellow
'pre[class*="language-"]': { tag: '#a78bfa', // Light Purple
color: '#e3e8f0', string: '#34d399', // Green
background: 'transparent', function: '#93bbfc', // Light Blue
textShadow: 'none', keyword: '#d8b4fe', // Light Purple
fontFamily: 'var(--font-mono)', variable: '#c084fc', // Purple
fontSize: '0.875em', operator: '#a1a1aa',
textAlign: 'left', },
whiteSpace: 'pre', light: {
wordSpacing: 'normal', base: '#1f2937',
wordBreak: 'normal', background: 'transparent',
wordWrap: 'normal', comment: '#9ca3af',
lineHeight: '1.5', punctuation: '#6b7280',
MozTabSize: '4', property: '#dc2626', // Red
OTabSize: '4', tag: '#7c3aed', // Purple
tabSize: '4', string: '#059669', // Green
WebkitHyphens: 'none', function: '#2563eb', // Blue
MozHyphens: 'none', keyword: '#9333ea', // Purple
msHyphens: 'none', variable: '#8b5cf6', // Violet
hyphens: 'none', operator: '#6b7280',
padding: '1em', },
margin: '0', white: {
overflow: 'auto', base: '#000000',
}, background: 'transparent',
':not(pre) > code[class*="language-"]': { comment: '#6b7280',
background: 'rgba(139, 92, 246, 0.1)', punctuation: '#374151',
padding: '0.1em 0.3em', property: '#dc2626', // Red
borderRadius: '0.3em', tag: '#5b21b6', // Deep Purple
whiteSpace: 'normal', string: '#047857', // Dark Green
}, function: '#1e40af', // Dark Blue
'comment': { keyword: '#6b21a8', // Dark Purple
color: '#6b7280', variable: '#6d28d9', // Dark Violet
fontStyle: 'italic', operator: '#374151',
}, },
'prolog': { custom: {
color: '#6b7280', // Default to dark theme colors for custom
}, base: '#e3e8f0',
'doctype': { background: 'transparent',
color: '#6b7280', comment: '#6b7280',
}, punctuation: '#9ca3af',
'cdata': { property: '#f59e0b',
color: '#6b7280', tag: '#8b5cf6',
}, string: '#10b981',
'punctuation': { function: '#818cf8',
color: '#9ca3af', keyword: '#c084fc',
}, variable: '#a78bfa',
'namespace': { operator: '#9ca3af',
opacity: '0.7', }
}, };
'property': {
color: '#f59e0b', // Amber/Orange const colors = themes[theme] || themes.dark;
},
'tag': { return {
color: '#8b5cf6', // Violet 'code[class*="language-"]': {
}, color: colors.base,
'boolean': { background: colors.background,
color: '#f59e0b', // Amber/Orange textShadow: 'none',
}, fontFamily: 'var(--font-mono)',
'number': { fontSize: '0.875em',
color: '#f59e0b', // Amber/Orange textAlign: 'left',
}, whiteSpace: 'pre',
'constant': { wordSpacing: 'normal',
color: '#f59e0b', // Amber/Orange wordBreak: 'normal',
}, wordWrap: 'normal',
'symbol': { lineHeight: '1.5',
color: '#f59e0b', // Amber/Orange MozTabSize: '4',
}, OTabSize: '4',
'deleted': { tabSize: '4',
color: '#ef4444', WebkitHyphens: 'none',
}, MozHyphens: 'none',
'selector': { msHyphens: 'none',
color: '#a78bfa', // Light Purple hyphens: 'none',
}, },
'attr-name': { 'pre[class*="language-"]': {
color: '#a78bfa', // Light Purple color: colors.base,
}, background: colors.background,
'string': { textShadow: 'none',
color: '#10b981', // Emerald Green fontFamily: 'var(--font-mono)',
}, fontSize: '0.875em',
'char': { textAlign: 'left',
color: '#10b981', // Emerald Green whiteSpace: 'pre',
}, wordSpacing: 'normal',
'builtin': { wordBreak: 'normal',
color: '#8b5cf6', // Violet wordWrap: 'normal',
}, lineHeight: '1.5',
'url': { MozTabSize: '4',
color: '#10b981', // Emerald Green OTabSize: '4',
}, tabSize: '4',
'inserted': { WebkitHyphens: 'none',
color: '#10b981', // Emerald Green MozHyphens: 'none',
}, msHyphens: 'none',
'entity': { hyphens: 'none',
color: '#a78bfa', // Light Purple padding: '1em',
cursor: 'help', margin: '0',
}, overflow: 'auto',
'atrule': { },
color: '#c084fc', // Light Violet ':not(pre) > code[class*="language-"]': {
}, background: theme === 'light' || theme === 'white'
'attr-value': { ? 'rgba(139, 92, 246, 0.1)'
color: '#10b981', // Emerald Green : 'rgba(139, 92, 246, 0.1)',
}, padding: '0.1em 0.3em',
'keyword': { borderRadius: '0.3em',
color: '#c084fc', // Light Violet whiteSpace: 'normal',
}, },
'function': { 'comment': {
color: '#818cf8', // Indigo color: colors.comment,
}, fontStyle: 'italic',
'class-name': { },
color: '#f59e0b', // Amber/Orange 'prolog': {
}, color: colors.comment,
'regex': { },
color: '#06b6d4', // Cyan 'doctype': {
}, color: colors.comment,
'important': { },
color: '#f59e0b', // Amber/Orange 'cdata': {
fontWeight: 'bold', color: colors.comment,
}, },
'variable': { 'punctuation': {
color: '#a78bfa', // Light Purple color: colors.punctuation,
}, },
'bold': { 'namespace': {
fontWeight: 'bold', opacity: '0.7',
}, },
'italic': { 'property': {
fontStyle: 'italic', color: colors.property,
}, },
'operator': { 'tag': {
color: '#9ca3af', color: colors.tag,
}, },
'script': { 'boolean': {
color: '#e3e8f0', color: colors.property,
}, },
'parameter': { 'number': {
color: '#fbbf24', // Yellow color: colors.property,
}, },
'method': { 'constant': {
color: '#818cf8', // Indigo color: colors.property,
}, },
'field': { 'symbol': {
color: '#f59e0b', // Amber/Orange color: colors.property,
}, },
'annotation': { 'deleted': {
color: '#6b7280', color: '#ef4444',
}, },
'type': { 'selector': {
color: '#a78bfa', // Light Purple color: colors.variable,
}, },
'module': { 'attr-name': {
color: '#8b5cf6', // Violet color: colors.variable,
}, },
}; 'string': {
color: colors.string,
},
'char': {
color: colors.string,
},
'builtin': {
color: colors.tag,
},
'url': {
color: colors.string,
},
'inserted': {
color: colors.string,
},
'entity': {
color: colors.variable,
cursor: 'help',
},
'atrule': {
color: colors.keyword,
},
'attr-value': {
color: colors.string,
},
'keyword': {
color: colors.keyword,
},
'function': {
color: colors.function,
},
'class-name': {
color: colors.property,
},
'regex': {
color: '#06b6d4', // Cyan
},
'important': {
color: colors.property,
fontWeight: 'bold',
},
'variable': {
color: colors.variable,
},
'bold': {
fontWeight: 'bold',
},
'italic': {
fontStyle: 'italic',
},
'operator': {
color: colors.operator,
},
'script': {
color: colors.base,
},
'parameter': {
color: colors.property,
},
'method': {
color: colors.function,
},
'field': {
color: colors.property,
},
'annotation': {
color: colors.comment,
},
'type': {
color: colors.variable,
},
'module': {
color: colors.tag,
},
};
};
// Export default dark theme for backward compatibility
export const claudeSyntaxTheme = getClaudeSyntaxTheme('dark');

View File

@@ -43,6 +43,95 @@
--ease-bounce: cubic-bezier(0.68, -0.55, 0.265, 1.55); --ease-bounce: cubic-bezier(0.68, -0.55, 0.265, 1.55);
} }
/* Theme Variations */
/* Default is dark theme - already defined above */
/* Light Theme */
.theme-light {
--color-background: oklch(0.98 0.01 240);
--color-foreground: oklch(0.12 0.01 240);
--color-card: oklch(0.96 0.01 240);
--color-card-foreground: oklch(0.12 0.01 240);
--color-popover: oklch(0.98 0.01 240);
--color-popover-foreground: oklch(0.12 0.01 240);
--color-primary: oklch(0.12 0.01 240);
--color-primary-foreground: oklch(0.98 0.01 240);
--color-secondary: oklch(0.94 0.01 240);
--color-secondary-foreground: oklch(0.12 0.01 240);
--color-muted: oklch(0.94 0.01 240);
--color-muted-foreground: oklch(0.45 0.01 240);
--color-accent: oklch(0.94 0.01 240);
--color-accent-foreground: oklch(0.12 0.01 240);
--color-destructive: oklch(0.6 0.2 25);
--color-destructive-foreground: oklch(0.98 0.01 240);
--color-border: oklch(0.90 0.01 240);
--color-input: oklch(0.90 0.01 240);
--color-ring: oklch(0.52 0.015 240);
/* Additional colors for status messages */
--color-green-500: oklch(0.62 0.20 142);
--color-green-600: oklch(0.54 0.22 142);
}
/* Gray Theme */
.theme-gray {
--color-background: oklch(0.22 0.01 240);
--color-foreground: oklch(0.98 0.01 240);
--color-card: oklch(0.26 0.01 240);
--color-card-foreground: oklch(0.98 0.01 240);
--color-popover: oklch(0.22 0.01 240);
--color-popover-foreground: oklch(0.98 0.01 240);
--color-primary: oklch(0.98 0.01 240);
--color-primary-foreground: oklch(0.22 0.01 240);
--color-secondary: oklch(0.30 0.01 240);
--color-secondary-foreground: oklch(0.98 0.01 240);
--color-muted: oklch(0.30 0.01 240);
--color-muted-foreground: oklch(0.70 0.01 240);
--color-accent: oklch(0.30 0.01 240);
--color-accent-foreground: oklch(0.98 0.01 240);
--color-destructive: oklch(0.6 0.2 25);
--color-destructive-foreground: oklch(0.98 0.01 240);
--color-border: oklch(0.30 0.01 240);
--color-input: oklch(0.30 0.01 240);
--color-ring: oklch(0.60 0.015 240);
/* Additional colors for status messages */
--color-green-500: oklch(0.72 0.20 142);
--color-green-600: oklch(0.64 0.22 142);
}
/* White Theme (High Contrast Light) */
.theme-white {
--color-background: oklch(1.0 0 240);
--color-foreground: oklch(0.0 0 240);
--color-card: oklch(0.98 0.01 240);
--color-card-foreground: oklch(0.0 0 240);
--color-popover: oklch(1.0 0 240);
--color-popover-foreground: oklch(0.0 0 240);
--color-primary: oklch(0.0 0 240);
--color-primary-foreground: oklch(1.0 0 240);
--color-secondary: oklch(0.96 0.01 240);
--color-secondary-foreground: oklch(0.0 0 240);
--color-muted: oklch(0.96 0.01 240);
--color-muted-foreground: oklch(0.35 0.01 240);
--color-accent: oklch(0.96 0.01 240);
--color-accent-foreground: oklch(0.0 0 240);
--color-destructive: oklch(0.55 0.25 25);
--color-destructive-foreground: oklch(1.0 0 240);
--color-border: oklch(0.85 0.01 240);
--color-input: oklch(0.85 0.01 240);
--color-ring: oklch(0.40 0.015 240);
/* Additional colors for status messages */
--color-green-500: oklch(0.55 0.25 142);
--color-green-600: oklch(0.47 0.27 142);
}
/* Custom Theme - CSS variables will be set dynamically by ThemeContext */
.theme-custom {
/* Custom theme variables are applied dynamically via JavaScript */
}
/* Reset and base styles */ /* Reset and base styles */
* { * {
border-color: var(--color-border); border-color: var(--color-border);
@@ -157,8 +246,10 @@ button:focus-visible,
} }
} }
/* Markdown Editor Dark Mode Styles */ /* Markdown Editor Theme-aware Styles */
[data-color-mode="dark"] { [data-color-mode="dark"],
.theme-dark [data-color-mode="dark"],
.theme-gray [data-color-mode="dark"] {
--color-border-default: rgb(48, 54, 61); --color-border-default: rgb(48, 54, 61);
--color-canvas-default: rgb(13, 17, 23); --color-canvas-default: rgb(13, 17, 23);
--color-canvas-subtle: rgb(22, 27, 34); --color-canvas-subtle: rgb(22, 27, 34);
@@ -169,6 +260,19 @@ button:focus-visible,
--color-danger-fg: rgb(248, 81, 73); --color-danger-fg: rgb(248, 81, 73);
} }
[data-color-mode="light"],
.theme-light [data-color-mode="light"],
.theme-white [data-color-mode="light"] {
--color-border-default: rgb(216, 222, 228);
--color-canvas-default: rgb(255, 255, 255);
--color-canvas-subtle: rgb(246, 248, 250);
--color-fg-default: rgb(31, 35, 40);
--color-fg-muted: rgb(101, 109, 118);
--color-fg-subtle: rgb(149, 157, 165);
--color-accent-fg: rgb(9, 105, 218);
--color-danger-fg: rgb(207, 34, 46);
}
.w-md-editor { .w-md-editor {
background-color: transparent !important; background-color: transparent !important;
color: var(--color-foreground) !important; color: var(--color-foreground) !important;