diff --git a/src/App.tsx b/src/App.tsx index b831440c..d3ce21de 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -395,6 +395,7 @@ function App() { onGoBack={handleGoBack} onGoForward={handleGoForward} leftPanelsCollapsed={!sidebarVisible && !noteListVisible} + isDarkTheme={themeManager.isDark} /> diff --git a/src/components/Editor.tsx b/src/components/Editor.tsx index 0f0235bb..13f670bc 100644 --- a/src/components/Editor.tsx +++ b/src/components/Editor.tsx @@ -60,6 +60,7 @@ interface EditorProps { onGoBack?: () => void onGoForward?: () => void leftPanelsCollapsed?: boolean + isDarkTheme?: boolean } function EditorEmptyState() { @@ -82,6 +83,7 @@ export const Editor = memo(function Editor({ onTrashNote, onRestoreNote, onArchiveNote, onUnarchiveNote, onRenameTab, onContentChange, onTitleSync, canGoBack, canGoForward, onGoBack, onGoForward, leftPanelsCollapsed, + isDarkTheme, }: EditorProps) { const vaultPathRef = useRef(vaultPath) useEffect(() => { vaultPathRef.current = vaultPath }, [vaultPath]) @@ -161,6 +163,7 @@ export const Editor = memo(function Editor({ onArchiveNote={onArchiveNote} onUnarchiveNote={onUnarchiveNote} vaultPath={vaultPath} + isDarkTheme={isDarkTheme} /> } {showRightPanel && } diff --git a/src/components/EditorContent.tsx b/src/components/EditorContent.tsx index b728bf21..fa4fd049 100644 --- a/src/components/EditorContent.tsx +++ b/src/components/EditorContent.tsx @@ -32,6 +32,7 @@ interface EditorContentProps { onArchiveNote?: (path: string) => void onUnarchiveNote?: (path: string) => void vaultPath?: string + isDarkTheme?: boolean } function EditorLoadingSkeleton() { @@ -97,7 +98,7 @@ function ActiveTabBreadcrumb({ activeTab, props }: { export function EditorContent({ activeTab, isLoadingNewTab, entries, editor, diffMode, diffContent, onToggleDiff, - onNavigateWikilink, onEditorChange, vaultPath, + onNavigateWikilink, onEditorChange, vaultPath, isDarkTheme, ...breadcrumbProps }: EditorContentProps) { return ( @@ -106,7 +107,7 @@ export function EditorContent({ {diffMode && } {!diffMode && activeTab && (
- +
)} {isLoadingNewTab && !diffMode && } diff --git a/src/components/SettingsPanel.test.tsx b/src/components/SettingsPanel.test.tsx index 7fdd8188..4481282c 100644 --- a/src/components/SettingsPanel.test.tsx +++ b/src/components/SettingsPanel.test.tsx @@ -40,6 +40,7 @@ const mockThemeManager: ThemeManager = { themes: [], activeThemeId: null, activeTheme: null, + isDark: false, switchTheme: vi.fn(), createTheme: vi.fn().mockResolvedValue('untitled'), reloadThemes: vi.fn(), diff --git a/src/components/SingleEditorView.tsx b/src/components/SingleEditorView.tsx index e55b761b..c438e371 100644 --- a/src/components/SingleEditorView.tsx +++ b/src/components/SingleEditorView.tsx @@ -23,12 +23,13 @@ function useInsertImageCallback(editor: ReturnType) { } /** Single BlockNote editor view — content is swapped via replaceBlocks */ -export function SingleEditorView({ editor, entries, onNavigateWikilink, onChange, vaultPath }: { +export function SingleEditorView({ editor, entries, onNavigateWikilink, onChange, vaultPath, isDarkTheme }: { editor: ReturnType entries: VaultEntry[] onNavigateWikilink: (target: string) => void onChange?: () => void vaultPath?: string + isDarkTheme?: boolean }) { const navigateRef = useRef(onNavigateWikilink) useEffect(() => { navigateRef.current = onNavigateWikilink }, [onNavigateWikilink]) @@ -100,7 +101,7 @@ export function SingleEditorView({ editor, entries, onNavigateWikilink, onChange )} ({ })) // Must import after mocks -const { useThemeManager } = await import('./useThemeManager') +const { useThemeManager, isColorDark } = await import('./useThemeManager') + +describe('isColorDark', () => { + it('identifies dark colors', () => { + expect(isColorDark('#000000')).toBe(true) + expect(isColorDark('#0F0F23')).toBe(true) + expect(isColorDark('#1a1a2e')).toBe(true) + expect(isColorDark('#0f0f1a')).toBe(true) + }) + + it('identifies light colors', () => { + expect(isColorDark('#FFFFFF')).toBe(false) + expect(isColorDark('#F7F6F3')).toBe(false) + expect(isColorDark('#E0E0E0')).toBe(false) + }) +}) describe('useThemeManager', () => { beforeEach(() => { @@ -228,6 +243,101 @@ describe('useThemeManager', () => { expect(newId).toBe('') }) + it('isDark is false for light theme', async () => { + const { result } = renderHook(() => useThemeManager('/vault')) + await waitFor(() => { + expect(result.current.activeTheme?.id).toBe('default') + }) + expect(result.current.isDark).toBe(false) + }) + + it('isDark is true for dark theme', async () => { + const { result } = renderHook(() => useThemeManager('/vault')) + await waitFor(() => { + expect(result.current.themes).toHaveLength(2) + }) + + await act(async () => { + await result.current.switchTheme('dark') + }) + + expect(result.current.isDark).toBe(true) + }) + + it('isDark is false when no active theme', async () => { + const { result } = renderHook(() => useThemeManager(null)) + expect(result.current.isDark).toBe(false) + }) + + it('derives app-specific CSS variables from theme colors', async () => { + const { result } = renderHook(() => useThemeManager('/vault')) + await waitFor(() => { + expect(result.current.activeTheme).not.toBeNull() + }) + + const root = document.documentElement + expect(root.style.getPropertyValue('--bg-primary')).toBe('#FFFFFF') + expect(root.style.getPropertyValue('--text-primary')).toBe('#1A1A2E') + expect(root.style.getPropertyValue('--text-heading')).toBe('#1A1A2E') + expect(root.style.getPropertyValue('--border-primary')).toBe('#E2E8F0') + }) + + it('sets color-scheme and data-theme-mode for dark theme', async () => { + const { result } = renderHook(() => useThemeManager('/vault')) + await waitFor(() => { + expect(result.current.themes).toHaveLength(2) + }) + + await act(async () => { + await result.current.switchTheme('dark') + }) + + const root = document.documentElement + await waitFor(() => { + expect(root.style.getPropertyValue('color-scheme')).toBe('dark') + }) + expect(root.dataset.themeMode).toBe('dark') + expect(root.style.getPropertyValue('--bg-primary')).toBe('#0F0F23') + expect(root.style.getPropertyValue('--text-primary')).toBe('#E2E8F0') + }) + + it('sets color-scheme to light for light theme', async () => { + const { result } = renderHook(() => useThemeManager('/vault')) + await waitFor(() => { + expect(result.current.activeTheme).not.toBeNull() + }) + + const root = document.documentElement + expect(root.style.getPropertyValue('color-scheme')).toBe('light') + expect(root.dataset.themeMode).toBe('light') + }) + + it('updates derived variables when switching between themes', async () => { + const { result } = renderHook(() => useThemeManager('/vault')) + await waitFor(() => { + expect(result.current.themes).toHaveLength(2) + }) + + await act(async () => { + await result.current.switchTheme('dark') + }) + + const root = document.documentElement + await waitFor(() => { + expect(root.style.getPropertyValue('--bg-primary')).toBe('#0F0F23') + }) + + await act(async () => { + await result.current.switchTheme('default') + }) + + await waitFor(() => { + expect(root.style.getPropertyValue('--bg-primary')).toBe('#FFFFFF') + }) + expect(root.style.getPropertyValue('color-scheme')).toBe('light') + expect(root.dataset.themeMode).toBe('light') + }) + it('reloadThemes re-fetches theme list', async () => { const { result } = renderHook(() => useThemeManager('/vault')) await waitFor(() => { diff --git a/src/hooks/useThemeManager.ts b/src/hooks/useThemeManager.ts index 4dc3b0f9..51f77654 100644 --- a/src/hooks/useThemeManager.ts +++ b/src/hooks/useThemeManager.ts @@ -7,6 +7,118 @@ function tauriCall(command: string, args: Record): Promise(command, args) : mockInvoke(command, args) } +// --- Color utilities for theme variable derivation --- + +function parseHex(hex: string): [number, number, number] { + const h = hex.replace('#', '') + return [parseInt(h.slice(0, 2), 16), parseInt(h.slice(2, 4), 16), parseInt(h.slice(4, 6), 16)] +} + +function toHex(r: number, g: number, b: number): string { + return '#' + [r, g, b].map(c => Math.round(Math.max(0, Math.min(255, c))).toString(16).padStart(2, '0')).join('') +} + +/** Blend two hex colors. ratio=0 → color1, ratio=1 → color2. */ +function mixColors(hex1: string, hex2: string, ratio: number): string { + const [r1, g1, b1] = parseHex(hex1) + const [r2, g2, b2] = parseHex(hex2) + return toHex(r1 + (r2 - r1) * ratio, g1 + (g2 - g1) * ratio, b1 + (b2 - b1) * ratio) +} + +/** Check if a hex color is perceptually dark (luminance < 0.5). */ +export function isColorDark(hex: string): boolean { + const [r, g, b] = parseHex(hex) + return (0.299 * r + 0.587 * g + 0.114 * b) / 255 < 0.5 +} + +// Variables derived from theme core colors (not present in theme.colors directly) +const DERIVED_VAR_NAMES = [ + 'bg-primary', 'bg-sidebar', 'bg-card', 'bg-hover', 'bg-hover-subtle', 'bg-selected', + 'bg-input', 'bg-button', 'bg-dialog', + 'text-primary', 'text-heading', 'text-secondary', 'text-tertiary', 'text-muted', 'text-faint', + 'border-primary', 'border-subtle', 'border-input', 'border-dialog', + 'link-color', 'link-hover', + // shadcn variables that may not be in the theme + 'card', 'card-foreground', 'popover', 'popover-foreground', + 'secondary', 'secondary-foreground', 'muted-foreground', + 'accent', 'accent-foreground', 'input', 'ring', + 'sidebar-foreground', 'sidebar-primary', 'sidebar-primary-foreground', + 'sidebar-accent', 'sidebar-accent-foreground', 'sidebar-border', 'sidebar-ring', +] + +/** Derive app-specific and missing shadcn CSS variables from core theme colors. */ +function deriveThemeVariables(root: HTMLElement, colors: Record): void { + const bg = colors.background + const fg = colors.foreground + if (!bg || !fg) return + + const isDark = isColorDark(bg) + root.style.setProperty('color-scheme', isDark ? 'dark' : 'light') + root.dataset.themeMode = isDark ? 'dark' : 'light' + + const primary = colors.primary ?? (isDark ? '#5C9CFF' : '#155DFF') + const border = colors.border ?? mixColors(bg, fg, isDark ? 0.15 : 0.08) + const muted = colors.muted ?? mixColors(bg, fg, isDark ? 0.08 : 0.05) + const sidebarBg = colors['sidebar-background'] ?? mixColors(bg, fg, 0.04) + + // App-specific variables + root.style.setProperty('--bg-primary', bg) + root.style.setProperty('--bg-sidebar', sidebarBg) + root.style.setProperty('--bg-card', mixColors(bg, fg, 0.03)) + root.style.setProperty('--bg-hover', mixColors(bg, fg, 0.1)) + root.style.setProperty('--bg-hover-subtle', muted) + root.style.setProperty('--bg-selected', `${primary}25`) + root.style.setProperty('--bg-input', bg) + root.style.setProperty('--bg-button', mixColors(bg, fg, 0.1)) + root.style.setProperty('--bg-dialog', mixColors(bg, fg, 0.02)) + + root.style.setProperty('--text-primary', fg) + root.style.setProperty('--text-heading', fg) + root.style.setProperty('--text-secondary', mixColors(fg, bg, 0.25)) + root.style.setProperty('--text-tertiary', mixColors(fg, bg, 0.35)) + root.style.setProperty('--text-muted', mixColors(fg, bg, 0.5)) + root.style.setProperty('--text-faint', mixColors(fg, bg, 0.6)) + + root.style.setProperty('--border-primary', border) + root.style.setProperty('--border-subtle', border) + root.style.setProperty('--border-input', border) + root.style.setProperty('--border-dialog', border) + + root.style.setProperty('--link-color', primary) + root.style.setProperty('--link-hover', mixColors(primary, fg, 0.2)) + + // Shadcn variables — only set if not already provided by the theme + const setIfMissing = (name: string, value: string) => { + if (!(name in colors)) root.style.setProperty(`--${name}`, value) + } + setIfMissing('card', mixColors(bg, fg, 0.03)) + setIfMissing('card-foreground', fg) + setIfMissing('popover', mixColors(bg, fg, 0.04)) + setIfMissing('popover-foreground', fg) + setIfMissing('secondary', mixColors(bg, fg, 0.08)) + setIfMissing('secondary-foreground', fg) + setIfMissing('muted-foreground', mixColors(fg, bg, 0.3)) + setIfMissing('accent', mixColors(bg, fg, 0.08)) + setIfMissing('accent-foreground', fg) + setIfMissing('input', border) + setIfMissing('ring', primary) + setIfMissing('sidebar-foreground', fg) + setIfMissing('sidebar-accent', mixColors(sidebarBg, fg, 0.1)) + setIfMissing('sidebar-accent-foreground', fg) + setIfMissing('sidebar-border', border) + setIfMissing('sidebar-primary', primary) + setIfMissing('sidebar-primary-foreground', '#FFFFFF') + setIfMissing('sidebar-ring', primary) +} + +function clearDerivedVariables(root: HTMLElement): void { + for (const name of DERIVED_VAR_NAMES) { + root.style.removeProperty(`--${name}`) + } + root.style.removeProperty('color-scheme') + delete root.dataset.themeMode +} + /** Map theme colors/typography/spacing to CSS custom properties on :root. */ function applyThemeToDom(theme: ThemeFile): void { const root = document.documentElement @@ -23,6 +135,7 @@ function applyThemeToDom(theme: ThemeFile): void { if (theme.colors['sidebar-background']) { root.style.setProperty('--sidebar', theme.colors['sidebar-background']) } + deriveThemeVariables(root, theme.colors) } function clearThemeFromDom(theme: ThemeFile): void { @@ -38,12 +151,14 @@ function clearThemeFromDom(theme: ThemeFile): void { root.style.removeProperty(`--theme-${key}`) } root.style.removeProperty('--sidebar') + clearDerivedVariables(root) } export interface ThemeManager { themes: ThemeFile[] activeThemeId: string | null activeTheme: ThemeFile | null + isDark: boolean switchTheme: (themeId: string) => Promise createTheme: (sourceId?: string) => Promise reloadThemes: () => Promise @@ -69,6 +184,7 @@ export function useThemeManager(vaultPath: string | null): ThemeManager { const prevThemeRef = useRef(null) const activeTheme = themes.find(t => t.id === activeThemeId) ?? null + const isDark = activeTheme?.colors.background ? isColorDark(activeTheme.colors.background) : false const loadThemes = useCallback(async () => { if (!vaultPath) return @@ -120,5 +236,5 @@ export function useThemeManager(vaultPath: string | null): ThemeManager { } }, [vaultPath, loadThemes, switchTheme]) - return { themes, activeThemeId, activeTheme, switchTheme, createTheme, reloadThemes: loadThemes } + return { themes, activeThemeId, activeTheme, isDark, switchTheme, createTheme, reloadThemes: loadThemes } } diff --git a/src/main.tsx b/src/main.tsx index 81f8777a..a8d69661 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -4,9 +4,6 @@ import { TooltipProvider } from '@/components/ui/tooltip' import './index.css' import App from './App.tsx' -// Force light mode — dark mode removed for now -document.documentElement.classList.remove('dark') - // Disable native WebKit context menu in Tauri (WKWebView intercepts right-click // at native level before React's synthetic events can call preventDefault). // Capture phase fires first → prevents native menu; React bubble phase still fires