From 6ec699cd949088fbe912bd99240dce6fbaca2d00 Mon Sep 17 00:00:00 2001 From: Tim Sinaeve Date: Fri, 24 Oct 2025 17:17:11 +0200 Subject: [PATCH 1/2] Refactor theme handling to use shared tokens --- contexts/ThemeContext.tsx | 115 +++++++++++++----- .../__tests__/markdownRenderer.test.tsx | 2 +- services/themeService.ts | 26 ++++ themes/__tests__/themeTokens.test.ts | 62 ++++++++++ themes/themeTokens.ts | 100 +++++++++++++++ 5 files changed, 271 insertions(+), 34 deletions(-) create mode 100644 services/themeService.ts create mode 100644 themes/__tests__/themeTokens.test.ts create mode 100644 themes/themeTokens.ts diff --git a/contexts/ThemeContext.tsx b/contexts/ThemeContext.tsx index 5f0d2bf..b5c9bdb 100644 --- a/contexts/ThemeContext.tsx +++ b/contexts/ThemeContext.tsx @@ -1,62 +1,111 @@ -import React, { createContext, useState, useEffect, useCallback, useMemo } from 'react'; +import React, { + createContext, + useCallback, + useEffect, + useLayoutEffect, + useMemo, + useRef, + useState, +} from 'react'; import { useLogger } from '../hooks/useLogger'; +import { ThemeId } from '../themes/themeTokens'; +import { applyTheme } from '../services/themeService'; -type Theme = 'light' | 'dark'; +type ThemeSource = 'storage' | 'system' | 'default'; interface ThemeContextType { - theme: Theme; + theme: ThemeId; + setTheme: (themeId: ThemeId) => void; toggleTheme: () => void; } +const resolveInitialTheme = (): { theme: ThemeId; source: ThemeSource } => { + if (typeof window === 'undefined') { + return { theme: 'light', source: 'default' }; + } + + const savedTheme = window.localStorage.getItem('theme'); + if (savedTheme === 'light' || savedTheme === 'dark') { + return { theme: savedTheme, source: 'storage' }; + } + + const prefersDark = window.matchMedia + ? window.matchMedia('(prefers-color-scheme: dark)').matches + : false; + if (prefersDark) { + return { theme: 'dark', source: 'system' }; + } + + return { theme: 'light', source: 'default' }; +}; + export const ThemeContext = createContext(undefined); export const ThemeProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => { - const [theme, setTheme] = useState('light'); // Default to light, will be updated by effect + const initialThemeRef = useRef<{ theme: ThemeId; source: ThemeSource } | null>(null); + const [theme, setThemeState] = useState(() => { + const resolved = resolveInitialTheme(); + initialThemeRef.current = resolved; + return resolved.theme; + }); const { addLog } = useLogger(); - // Effect to set initial theme from localStorage or system preference + useLayoutEffect(() => { + applyTheme(theme); + }, [theme]); + useEffect(() => { - const savedTheme = localStorage.getItem('theme') as Theme | null; - const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches; - let initialTheme: Theme = 'light'; - - if (savedTheme) { - initialTheme = savedTheme; - addLog('DEBUG', `Theme loaded from localStorage: "${savedTheme}"`); - } else if (prefersDark) { - initialTheme = 'dark'; - addLog('DEBUG', 'System preference for dark theme detected.'); - } else { - addLog('DEBUG', 'Defaulting to light theme.'); + if (initialThemeRef.current) { + const { theme: initialTheme, source } = initialThemeRef.current; + if (source === 'storage') { + addLog('DEBUG', `Theme loaded from localStorage: "${initialTheme}"`); + } else if (source === 'system') { + addLog('DEBUG', 'System preference for dark theme detected.'); + } else { + addLog('DEBUG', 'Defaulting to light theme.'); + } + initialThemeRef.current = null; } - setTheme(initialTheme); }, [addLog]); - // Effect to apply theme class to the document and save to localStorage useEffect(() => { - const root = document.documentElement; - if (theme === 'dark') { - root.classList.add('dark'); - } else { - root.classList.remove('dark'); + if (typeof window !== 'undefined') { + window.localStorage.setItem('theme', theme); } - localStorage.setItem('theme', theme); addLog('DEBUG', `Applied theme "${theme}" to document.`); }, [theme, addLog]); + const setTheme = useCallback( + (nextTheme: ThemeId) => { + setThemeState((prevTheme) => { + if (prevTheme === nextTheme) { + addLog('DEBUG', `Theme "${nextTheme}" already applied.`); + return prevTheme; + } + + addLog('INFO', `Theme set to "${nextTheme}".`); + return nextTheme; + }); + }, + [addLog], + ); + const toggleTheme = useCallback(() => { - setTheme((prevTheme) => { - const newTheme = prevTheme === 'light' ? 'dark' : 'light'; + setThemeState((prevTheme) => { + const newTheme: ThemeId = prevTheme === 'light' ? 'dark' : 'light'; addLog('INFO', `User toggled theme to "${newTheme}".`); return newTheme; }); }, [addLog]); - const value = useMemo(() => ({ theme, toggleTheme }), [theme, toggleTheme]); - - return ( - - {children} - + const value = useMemo( + () => ({ + theme, + setTheme, + toggleTheme, + }), + [theme, setTheme, toggleTheme], ); + + return {children}; }; diff --git a/services/preview/__tests__/markdownRenderer.test.tsx b/services/preview/__tests__/markdownRenderer.test.tsx index 9ab4160..fe84e1a 100644 --- a/services/preview/__tests__/markdownRenderer.test.tsx +++ b/services/preview/__tests__/markdownRenderer.test.tsx @@ -66,7 +66,7 @@ const renderMarkdown = async (markdown: string, theme: 'light' | 'dark' = 'light let utils: ReturnType | undefined; await act(async () => { utils = render( - + {output} , ); diff --git a/services/themeService.ts b/services/themeService.ts new file mode 100644 index 0000000..f3d2e6c --- /dev/null +++ b/services/themeService.ts @@ -0,0 +1,26 @@ +import { ThemeDefinition, ThemeId, applyThemeTokens, themeDefinitions } from '../themes/themeTokens'; + +export interface ApplyThemeOptions { + overrides?: Partial; + root?: HTMLElement | null; +} + +export const applyTheme = ( + themeId: ThemeId, + options: ApplyThemeOptions = {}, +): ThemeDefinition => { + const { overrides, root = typeof document !== 'undefined' ? document.documentElement : null } = options; + const baseDefinition = themeDefinitions[themeId]; + const mergedDefinition = { + ...baseDefinition, + ...overrides, + } as ThemeDefinition; + + applyThemeTokens(mergedDefinition, root); + + if (root) { + root.classList.toggle('dark', themeId === 'dark'); + } + + return mergedDefinition; +}; diff --git a/themes/__tests__/themeTokens.test.ts b/themes/__tests__/themeTokens.test.ts new file mode 100644 index 0000000..53840b0 --- /dev/null +++ b/themes/__tests__/themeTokens.test.ts @@ -0,0 +1,62 @@ +import { describe, expect, it } from 'vitest'; +import { readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; +import { + ThemeDefinition, + ThemeSlot, + lightThemeDefinition, + darkThemeDefinition, + THEME_SLOTS, +} from '../themeTokens'; + +const escapeForRegex = (value: string) => value.replace(/[.*+?^${}()|[\\]\\]/g, '\\$&'); + +const loadFallbackVariables = (selector: string): Map => { + const htmlPath = resolve(__dirname, '../../index.html'); + const file = readFileSync(htmlPath, 'utf8'); + const pattern = new RegExp(`${escapeForRegex(selector)}\\s*{([^}]*)}`); + const match = file.match(pattern); + if (!match) { + throw new Error(`Failed to locate fallback variables for selector "${selector}".`); + } + + const entries = new Map(); + const block = match[1]; + const variableRegex = /--([a-z0-9-]+):\s*([^;]+);/gi; + let variableMatch: RegExpExecArray | null; + + while ((variableMatch = variableRegex.exec(block)) !== null) { + const [, slot, value] = variableMatch; + entries.set(slot as ThemeSlot, value.trim()); + } + + return entries; +}; + +const expectDefinitionMatches = (definition: ThemeDefinition, selectors: string | string[]) => { + const selectorsArray = Array.isArray(selectors) ? selectors : [selectors]; + const fallbackVariables = new Map(); + + for (const selector of selectorsArray) { + const variables = loadFallbackVariables(selector); + for (const [slot, value] of variables) { + fallbackVariables.set(slot, value); + } + } + + for (const slot of THEME_SLOTS) { + const fallbackValue = fallbackVariables.get(slot); + expect(fallbackValue).toBeDefined(); + expect(definition[slot]).toBe(fallbackValue); + } +}; + +describe('theme token fallbacks', () => { + it('keeps the light theme fallback values in sync', () => { + expectDefinitionMatches(lightThemeDefinition, ':root'); + }); + + it('keeps the dark theme fallback values in sync', () => { + expectDefinitionMatches(darkThemeDefinition, [':root', '.dark']); + }); +}); diff --git a/themes/themeTokens.ts b/themes/themeTokens.ts new file mode 100644 index 0000000..3d478b1 --- /dev/null +++ b/themes/themeTokens.ts @@ -0,0 +1,100 @@ +export const THEME_SLOTS = [ + 'color-background', + 'color-secondary', + 'color-text-main', + 'color-text-secondary', + 'color-border', + 'color-accent', + 'color-accent-hover', + 'color-accent-text', + 'color-success', + 'color-warning', + 'color-error', + 'color-info', + 'color-debug', + 'color-destructive-text', + 'color-destructive-bg', + 'color-destructive-bg-hover', + 'color-destructive-border', + 'color-modal-backdrop', + 'color-tooltip-bg', + 'color-tooltip-text', + 'color-tree-selected', + 'select-arrow-background', +] as const; + +export type ThemeSlot = typeof THEME_SLOTS[number]; + +export type ThemeDefinition = Record; + +export type ThemeId = 'light' | 'dark'; + +const baseLightTheme: ThemeDefinition = { + 'color-background': '245 245 245', + 'color-secondary': '255 255 255', + 'color-text-main': '23 23 23', + 'color-text-secondary': '82 82 82', + 'color-border': '229 231 235', + 'color-accent': '99 102 241', + 'color-accent-hover': '80 70 229', + 'color-accent-text': '255 255 255', + 'color-success': '34 197 94', + 'color-warning': '249 115 22', + 'color-error': '239 68 68', + 'color-info': '59 130 246', + 'color-debug': '22 163 74', + 'color-destructive-text': '185 28 28', + 'color-destructive-bg': '254 226 226', + 'color-destructive-bg-hover': '254 202 202', + 'color-destructive-border': '252 165 165', + 'color-modal-backdrop': '0 0 0 / 0.5', + 'color-tooltip-bg': '23 23 23', + 'color-tooltip-text': '245 245 245', + 'color-tree-selected': '212 212 212', + 'select-arrow-background': + "url(\"data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 20 20'%3e%3cpath stroke='%23525252' stroke-linecap='round' stroke-linejoin='round' stroke-width='1.5' d='M6 8l4 4 4-4'/%3e%3c/svg%3e\")", +}; + +export const lightThemeDefinition: ThemeDefinition = Object.freeze({ + ...baseLightTheme, +}); + +export const darkThemeDefinition: ThemeDefinition = Object.freeze({ + ...baseLightTheme, + 'color-background': '23 23 23', + 'color-secondary': '38 38 38', + 'color-text-main': '245 245 245', + 'color-text-secondary': '163 163 163', + 'color-border': '64 64 64', + 'color-accent': '129 140 248', + 'color-accent-hover': '99 102 241', + 'color-accent-text': '23 23 23', + 'color-destructive-text': '252 165 165', + 'color-destructive-bg': '127 29 29 / 0.5', + 'color-destructive-bg-hover': '127 29 29 / 0.8', + 'color-destructive-border': '153 27 27', + 'color-modal-backdrop': '0 0 0 / 0.7', + 'color-tooltip-bg': '245 245 245', + 'color-tooltip-text': '23 23 23', + 'color-tree-selected': '56 56 56', + 'select-arrow-background': + "url(\"data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 20 20'%3e%3cpath stroke='%23a3a3a3' stroke-linecap='round' stroke-linejoin='round' stroke-width='1.5' d='M6 8l4 4 4-4'/%3e%3c/svg%3e\")", +}); + +export const themeDefinitions: Record = Object.freeze({ + light: lightThemeDefinition, + dark: darkThemeDefinition, +}); + +export const applyThemeTokens = ( + definition: ThemeDefinition, + root: HTMLElement | null = typeof document !== 'undefined' ? document.documentElement : null, +): void => { + if (!root) { + return; + } + + for (const [slot, value] of Object.entries(definition) as [ThemeSlot, string][]) { + root.style.setProperty(`--${slot}`, value); + } +}; From 0439cb65da929e18b474b6cc48955a11f26a262a Mon Sep 17 00:00:00 2001 From: Tim Sinaeve Date: Sat, 25 Oct 2025 18:45:24 +0200 Subject: [PATCH 2/2] Persist theme overrides in settings --- App.tsx | 8 ++ constants.ts | 10 ++ hooks/useSettings.ts | 26 +++++ services/__tests__/themeService.test.ts | 72 +++++++++++++ services/themeService.ts | 131 +++++++++++++++++++++++- themes/themeTokens.ts | 2 + types.ts | 9 ++ 7 files changed, 255 insertions(+), 3 deletions(-) create mode 100644 services/__tests__/themeService.test.ts diff --git a/App.tsx b/App.tsx index 8654ddb..dc925a6 100644 --- a/App.tsx +++ b/App.tsx @@ -35,6 +35,7 @@ import { storageService } from './services/storageService'; import { llmDiscoveryService } from './services/llmDiscoveryService'; import { LOCAL_STORAGE_KEYS, DEFAULT_SETTINGS } from './constants'; import { repository } from './services/repository'; +import { configureThemePreferences } from './services/themeService'; import { DocumentNode } from './components/PromptTreeItem'; import { formatShortcut, getShortcutMap, formatShortcutForDisplay } from './services/shortcutService'; import { readClipboardText, ClipboardPermissionError, ClipboardUnavailableError } from './services/clipboardService'; @@ -382,6 +383,13 @@ const MainApp: React.FC = () => { } }, [settings.uiScale, settingsLoaded]); + useEffect(() => { + if (!settingsLoaded) { + return; + } + configureThemePreferences(settings.themePreferences); + }, [configureThemePreferences, settings.themePreferences, settingsLoaded]); + useEffect(() => { if (settingsLoaded) { document.documentElement.style.setProperty('--markdown-font-size', `${settings.markdownFontSize}px`); diff --git a/constants.ts b/constants.ts index 6d1d3f5..965ec52 100644 --- a/constants.ts +++ b/constants.ts @@ -47,6 +47,16 @@ export const DEFAULT_SETTINGS: Settings = { markdownCodeBlockBackgroundDark: '#1f2933', markdownContentPadding: 48, markdownParagraphSpacing: 0.75, + themePreferences: { + light: { + overrides: {}, + contrastOffset: 0, + }, + dark: { + overrides: {}, + contrastOffset: 0, + }, + }, pythonDefaults: { targetPythonVersion: '3.11', basePackages: [ diff --git a/hooks/useSettings.ts b/hooks/useSettings.ts index ab5b999..ded3ff2 100644 --- a/hooks/useSettings.ts +++ b/hooks/useSettings.ts @@ -26,6 +26,32 @@ export const useSettings = () => { // Merge with defaults to ensure all properties exist for existing users after an update. const mergedSettings = { ...DEFAULT_SETTINGS, ...loadedSettingsFromDB }; + const defaultThemePrefs = DEFAULT_SETTINGS.themePreferences; + const persistedThemePrefs = ( + (loadedSettingsFromDB as Partial).themePreferences ?? {} + ) as Partial; + mergedSettings.themePreferences = { + light: { + overrides: { + ...defaultThemePrefs.light.overrides, + ...(persistedThemePrefs.light?.overrides ?? {}), + }, + contrastOffset: + typeof persistedThemePrefs.light?.contrastOffset === 'number' + ? persistedThemePrefs.light.contrastOffset + : defaultThemePrefs.light.contrastOffset, + }, + dark: { + overrides: { + ...defaultThemePrefs.dark.overrides, + ...(persistedThemePrefs.dark?.overrides ?? {}), + }, + contrastOffset: + typeof persistedThemePrefs.dark?.contrastOffset === 'number' + ? persistedThemePrefs.dark.contrastOffset + : defaultThemePrefs.dark.contrastOffset, + }, + }; // If provider name is missing but URL is present, try to discover it. if (mergedSettings.llmProviderUrl && !mergedSettings.llmProviderName) { diff --git a/services/__tests__/themeService.test.ts b/services/__tests__/themeService.test.ts new file mode 100644 index 0000000..f17c7fb --- /dev/null +++ b/services/__tests__/themeService.test.ts @@ -0,0 +1,72 @@ +import { beforeEach, describe, expect, it } from 'vitest'; +import { applyTheme, configureThemePreferences } from '../themeService'; + +const resetDocumentStyles = () => { + document.documentElement.style.cssText = ''; + document.documentElement.className = ''; +}; + +describe('themeService', () => { + beforeEach(() => { + configureThemePreferences({ + light: { overrides: {}, contrastOffset: 0 }, + dark: { overrides: {}, contrastOffset: 0 }, + }); + resetDocumentStyles(); + applyTheme('light'); + }); + + it('merges stored overrides before applying tokens', () => { + configureThemePreferences({ + light: { + overrides: { + 'color-background': '10 20 30', + }, + }, + }); + + const styles = document.documentElement.style; + expect(styles.getPropertyValue('--color-background')).toBe('10 20 30'); + expect(document.documentElement.classList.contains('dark')).toBe(false); + }); + + it('reapplies the current theme when preferences are updated', () => { + applyTheme('dark'); + + configureThemePreferences({ + dark: { + overrides: { + 'color-text-main': '200 200 200', + }, + }, + }); + + const styles = document.documentElement.style; + expect(styles.getPropertyValue('--color-text-main')).toBe('200 200 200'); + expect(document.documentElement.classList.contains('dark')).toBe(true); + }); + + it('applies contrast offsets to base theme values', () => { + configureThemePreferences({ + light: { contrastOffset: -10 }, + }); + + const styles = document.documentElement.style; + expect(styles.getPropertyValue('--color-text-main')).toBe('13 13 13'); + }); + + it('preserves runtime overrides across preference updates', () => { + applyTheme('light', { + overrides: { + 'color-accent': '1 2 3', + }, + }); + + configureThemePreferences({ + light: { overrides: {}, contrastOffset: 0 }, + }); + + const styles = document.documentElement.style; + expect(styles.getPropertyValue('--color-accent')).toBe('1 2 3'); + }); +}); diff --git a/services/themeService.ts b/services/themeService.ts index f3d2e6c..f10fb41 100644 --- a/services/themeService.ts +++ b/services/themeService.ts @@ -1,4 +1,113 @@ import { ThemeDefinition, ThemeId, applyThemeTokens, themeDefinitions } from '../themes/themeTokens'; +import type { ThemePreference } from '../types'; + +type ThemePreferenceUpdate = Partial>>; + +const clampContrastOffset = (value: number | null | undefined): number => { + if (typeof value !== 'number' || Number.isNaN(value) || !Number.isFinite(value)) { + return 0; + } + return Math.max(-255, Math.min(255, value)); +}; + +const createDefaultPreference = (): ThemePreference => ({ + overrides: {}, + contrastOffset: 0, +}); + +const clonePreference = (preference: ThemePreference): ThemePreference => ({ + overrides: { ...preference.overrides }, + contrastOffset: preference.contrastOffset, +}); + +const sanitizePreference = ( + next: Partial | undefined, + current: ThemePreference, +): ThemePreference => { + if (!next) { + return clonePreference(current); + } + + const hasOverrides = Object.prototype.hasOwnProperty.call(next, 'overrides'); + const overrides = hasOverrides ? { ...(next.overrides ?? {}) } : { ...current.overrides }; + + const hasContrast = Object.prototype.hasOwnProperty.call(next, 'contrastOffset'); + const contrastOffset = hasContrast ? clampContrastOffset(next.contrastOffset) : current.contrastOffset; + + return { + overrides, + contrastOffset, + }; +}; + +let storedPreferences: Record = { + light: createDefaultPreference(), + dark: createDefaultPreference(), +}; + +interface LastApplyState { + themeId: ThemeId; + root: HTMLElement | null; + overrides?: Partial; +} + +let lastApplyState: LastApplyState | null = null; + +const RGB_WITH_OPTIONAL_ALPHA = /^(\d{1,3})\s+(\d{1,3})\s+(\d{1,3})(\s*\/\s*(?:0|1|0?\.\d+|\d{1,3}%))?$/; + +const applyContrastOffset = (value: string, offset: number): string => { + if (!offset) { + return value; + } + + const match = RGB_WITH_OPTIONAL_ALPHA.exec(value.trim()); + if (!match) { + return value; + } + + const [, rawRed, rawGreen, rawBlue, alphaPart = ''] = match; + const adjustComponent = (component: string) => { + const numeric = Number(component); + if (Number.isNaN(numeric)) { + return component; + } + const adjusted = Math.max(0, Math.min(255, numeric + offset)); + return String(Math.round(adjusted)); + }; + + const red = adjustComponent(rawRed); + const green = adjustComponent(rawGreen); + const blue = adjustComponent(rawBlue); + + return `${red} ${green} ${blue}${alphaPart}`; +}; + +const applyContrastToDefinition = (definition: ThemeDefinition, offset: number): ThemeDefinition => { + if (!offset) { + return { ...definition }; + } + + const adjustedEntries = Object.entries(definition).map(([slot, value]) => [ + slot, + applyContrastOffset(value as string, offset), + ]); + + return Object.fromEntries(adjustedEntries) as ThemeDefinition; +}; + +export const configureThemePreferences = (preferences: ThemePreferenceUpdate = {}): void => { + storedPreferences = { + light: sanitizePreference(preferences.light, storedPreferences.light), + dark: sanitizePreference(preferences.dark, storedPreferences.dark), + }; + + if (lastApplyState) { + applyTheme(lastApplyState.themeId, { + root: lastApplyState.root, + overrides: lastApplyState.overrides, + }); + } +}; export interface ApplyThemeOptions { overrides?: Partial; @@ -9,11 +118,18 @@ export const applyTheme = ( themeId: ThemeId, options: ApplyThemeOptions = {}, ): ThemeDefinition => { - const { overrides, root = typeof document !== 'undefined' ? document.documentElement : null } = options; + const { + overrides: runtimeOverrides, + root = typeof document !== 'undefined' ? document.documentElement : null, + } = options; + + const preference = storedPreferences[themeId] ?? createDefaultPreference(); const baseDefinition = themeDefinitions[themeId]; + const baseWithContrast = applyContrastToDefinition(baseDefinition, preference.contrastOffset); const mergedDefinition = { - ...baseDefinition, - ...overrides, + ...baseWithContrast, + ...preference.overrides, + ...(runtimeOverrides ?? {}), } as ThemeDefinition; applyThemeTokens(mergedDefinition, root); @@ -22,5 +138,14 @@ export const applyTheme = ( root.classList.toggle('dark', themeId === 'dark'); } + lastApplyState = { + themeId, + root, + overrides: + runtimeOverrides && Object.keys(runtimeOverrides).length > 0 + ? { ...runtimeOverrides } + : undefined, + }; + return mergedDefinition; }; diff --git a/themes/themeTokens.ts b/themes/themeTokens.ts index 3d478b1..c843828 100644 --- a/themes/themeTokens.ts +++ b/themes/themeTokens.ts @@ -27,6 +27,8 @@ export type ThemeSlot = typeof THEME_SLOTS[number]; export type ThemeDefinition = Record; +export type ThemeOverrides = Partial>; + export type ThemeId = 'light' | 'dark'; const baseLightTheme: ThemeDefinition = { diff --git a/types.ts b/types.ts index 19bc121..cd1ee4f 100644 --- a/types.ts +++ b/types.ts @@ -1,4 +1,5 @@ import type React from 'react'; +import type { ThemeId, ThemeOverrides } from './themes/themeTokens'; // Fix: Add global declaration for window.electronAPI to inform TypeScript of the preload script's additions. // Added NodeJS.Process augmentation to fix type error in main process. @@ -333,6 +334,13 @@ export interface DocumentTemplate { updated_at: string; } +export interface ThemePreference { + overrides: ThemeOverrides; + contrastOffset: number; +} + +export type ThemePreferences = Record; + export interface Settings { llmProviderUrl: string; llmModelName: string; @@ -363,6 +371,7 @@ export interface Settings { markdownCodeBlockBackgroundDark: string; markdownContentPadding: number; markdownParagraphSpacing: number; + themePreferences: ThemePreferences; pythonDefaults: PythonEnvironmentDefaults; pythonWorkingDirectory: string | null; pythonConsoleTheme: 'light' | 'dark';