* feat: add theming system design file with 3 frames Design frames for Settings > Appearance panel, Command Palette theme switching, and live theme editing flow visualization. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * feat: add Rust backend for vault-native theming system Theme files live in _themes/*.json with colors, typography, and spacing sections. Vault-level settings (.laputa/settings.json) store the active theme. Built-in themes (default, dark, minimal) are seeded on vault creation. All 6 Tauri commands registered: list_themes, get_theme, get_vault_settings, save_vault_settings, set_active_theme, create_theme. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * feat: add frontend theme engine, settings UI, and command palette integration Wire up useThemeManager hook to load themes via Tauri IPC, apply CSS custom properties to :root, and support switching/creating themes. Add Appearance section to Settings panel with color swatches and theme cards. Register theme switch and create commands in the command registry. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: use .to_string() instead of format! for string literal (clippy) * style: cargo fmt on theme.rs --------- Co-authored-by: Test <test@test.com> Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
119 lines
4.0 KiB
TypeScript
119 lines
4.0 KiB
TypeScript
import { useCallback, useEffect, useRef, useState } from 'react'
|
|
import { invoke } from '@tauri-apps/api/core'
|
|
import { isTauri, mockInvoke } from '../mock-tauri'
|
|
import type { ThemeFile, VaultSettings } from '../types'
|
|
|
|
function tauriCall<T>(command: string, args: Record<string, unknown>): Promise<T> {
|
|
return isTauri() ? invoke<T>(command, args) : mockInvoke<T>(command, args)
|
|
}
|
|
|
|
/** Map theme colors/typography/spacing to CSS custom properties on :root. */
|
|
function applyThemeToDom(theme: ThemeFile): void {
|
|
const root = document.documentElement
|
|
for (const [key, value] of Object.entries(theme.colors)) {
|
|
root.style.setProperty(`--theme-${key}`, value)
|
|
// Also set the shadcn-compatible variables
|
|
root.style.setProperty(`--${key}`, value)
|
|
}
|
|
for (const [key, value] of Object.entries(theme.typography)) {
|
|
root.style.setProperty(`--theme-${key}`, value)
|
|
}
|
|
for (const [key, value] of Object.entries(theme.spacing)) {
|
|
root.style.setProperty(`--theme-${key}`, value)
|
|
}
|
|
// Map sidebar-background to --sidebar (shadcn convention)
|
|
if (theme.colors['sidebar-background']) {
|
|
root.style.setProperty('--sidebar', theme.colors['sidebar-background'])
|
|
}
|
|
}
|
|
|
|
function clearThemeFromDom(theme: ThemeFile): void {
|
|
const root = document.documentElement
|
|
for (const key of Object.keys(theme.colors)) {
|
|
root.style.removeProperty(`--theme-${key}`)
|
|
root.style.removeProperty(`--${key}`)
|
|
}
|
|
for (const key of Object.keys(theme.typography)) {
|
|
root.style.removeProperty(`--theme-${key}`)
|
|
}
|
|
for (const key of Object.keys(theme.spacing)) {
|
|
root.style.removeProperty(`--theme-${key}`)
|
|
}
|
|
root.style.removeProperty('--sidebar')
|
|
}
|
|
|
|
export interface ThemeManager {
|
|
themes: ThemeFile[]
|
|
activeThemeId: string | null
|
|
activeTheme: ThemeFile | null
|
|
switchTheme: (themeId: string) => Promise<void>
|
|
createTheme: (sourceId?: string) => Promise<string>
|
|
reloadThemes: () => Promise<void>
|
|
}
|
|
|
|
/** Sync CSS custom properties: clear old theme, apply new one. */
|
|
function syncThemeDom(
|
|
prevRef: React.MutableRefObject<ThemeFile | null>,
|
|
theme: ThemeFile | null,
|
|
): void {
|
|
if (prevRef.current) clearThemeFromDom(prevRef.current)
|
|
if (theme) {
|
|
applyThemeToDom(theme)
|
|
prevRef.current = theme
|
|
} else {
|
|
prevRef.current = null
|
|
}
|
|
}
|
|
|
|
export function useThemeManager(vaultPath: string | null): ThemeManager {
|
|
const [themes, setThemes] = useState<ThemeFile[]>([])
|
|
const [activeThemeId, setActiveThemeId] = useState<string | null>(null)
|
|
const prevThemeRef = useRef<ThemeFile | null>(null)
|
|
|
|
const activeTheme = themes.find(t => t.id === activeThemeId) ?? null
|
|
|
|
const loadThemes = useCallback(async () => {
|
|
if (!vaultPath) return
|
|
try {
|
|
const [themeList, settings] = await Promise.all([
|
|
tauriCall<ThemeFile[]>('list_themes', { vault_path: vaultPath }),
|
|
tauriCall<VaultSettings>('get_vault_settings', { vault_path: vaultPath }),
|
|
])
|
|
setThemes(themeList)
|
|
setActiveThemeId(settings.theme)
|
|
} catch (err) {
|
|
console.warn('Failed to load themes:', err)
|
|
}
|
|
}, [vaultPath])
|
|
|
|
useEffect(() => { loadThemes() }, [loadThemes]) // eslint-disable-line react-hooks/set-state-in-effect -- trigger initial load
|
|
useEffect(() => { syncThemeDom(prevThemeRef, activeTheme) }, [activeTheme])
|
|
|
|
const switchTheme = useCallback(async (themeId: string) => {
|
|
if (!vaultPath) return
|
|
try {
|
|
await tauriCall<null>('set_active_theme', { vault_path: vaultPath, theme_id: themeId })
|
|
setActiveThemeId(themeId)
|
|
} catch (err) {
|
|
console.error('Failed to switch theme:', err)
|
|
}
|
|
}, [vaultPath])
|
|
|
|
const createTheme = useCallback(async (sourceId?: string) => {
|
|
if (!vaultPath) return ''
|
|
try {
|
|
const newId = await tauriCall<string>('create_theme', {
|
|
vault_path: vaultPath,
|
|
source_id: sourceId ?? null,
|
|
})
|
|
await loadThemes()
|
|
return newId
|
|
} catch (err) {
|
|
console.error('Failed to create theme:', err)
|
|
return ''
|
|
}
|
|
}, [vaultPath, loadThemes])
|
|
|
|
return { themes, activeThemeId, activeTheme, switchTheme, createTheme, reloadThemes: loadThemes }
|
|
}
|