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