From 19bc3c67e3b9d6fa5d9ade7910d1b0d050233e34 Mon Sep 17 00:00:00 2001 From: Test Date: Mon, 2 Mar 2026 21:05:20 +0100 Subject: [PATCH] =?UTF-8?q?wip:=20themes-editable=20=E2=80=94=20theme=20ma?= =?UTF-8?q?nagement=20system=20WIP=20(13=20files,=201014=20insertions)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .claude-pid | 2 +- design/themes-editable.pen | 1 + src-tauri/src/lib.rs | 13 +- src-tauri/src/theme.rs | 420 ++++++++++++++++++++++++++++-- src/App.tsx | 12 +- src/components/Sidebar.tsx | 3 +- src/hooks/useAppCommands.ts | 2 + src/hooks/useCommandRegistry.ts | 41 ++- src/hooks/useThemeManager.test.ts | 402 +++++++++++++++++----------- src/hooks/useThemeManager.ts | 231 +++++++++++----- src/mock-tauri/mock-content.ts | 63 +++++ src/mock-tauri/mock-entries.ts | 85 ++++++ src/mock-tauri/mock-handlers.ts | 26 ++ src/types.ts | 3 + 14 files changed, 1045 insertions(+), 259 deletions(-) create mode 100644 design/themes-editable.pen diff --git a/.claude-pid b/.claude-pid index db8555f6..0d69e566 100644 --- a/.claude-pid +++ b/.claude-pid @@ -1 +1 @@ -81859 +24855 diff --git a/design/themes-editable.pen b/design/themes-editable.pen new file mode 100644 index 00000000..747b5ab3 --- /dev/null +++ b/design/themes-editable.pen @@ -0,0 +1 @@ +{"children":[],"variables":{}} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 143c2fb6..bab8ca25 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -372,6 +372,12 @@ fn create_theme(vault_path: String, source_id: Option) -> Result) -> Result { + let vault_path = expand_tilde(&vault_path); + theme::create_vault_theme(&vault_path, name.as_deref()) +} + fn log_startup_result(label: &str, result: Result) { match result { Ok(n) if n > 0 => log::info!("{}: {} files", label, n), @@ -398,8 +404,10 @@ fn run_startup_tasks() { vault::migrate_is_a_to_type(vp_str), ); - // Seed _themes/ with built-in themes if missing + // Seed _themes/ with built-in JSON themes (legacy) if missing theme::seed_default_themes(vp_str); + // Seed theme/ with built-in vault theme notes if missing + theme::seed_vault_themes(vp_str); // Register Laputa MCP server in Claude Code and Cursor configs match mcp::register_mcp(vp_str) { @@ -549,7 +557,8 @@ pub fn run() { get_vault_settings, save_vault_settings, set_active_theme, - create_theme + create_theme, + create_vault_theme ]) .build(tauri::generate_context!()) .expect("error while building tauri application") diff --git a/src-tauri/src/theme.rs b/src-tauri/src/theme.rs index 4ecfce1b..fc5907ba 100644 --- a/src-tauri/src/theme.rs +++ b/src-tauri/src/theme.rs @@ -116,20 +116,98 @@ pub fn get_theme(vault_path: &str, theme_id: &str) -> Result parse_theme_file(&path) } +/// Create `dir` and write each `(filename, content)` pair if the directory doesn't exist yet. +fn seed_dir_with_files(dir: &Path, files: &[(&str, &str)], log_msg: &str) { + if dir.is_dir() { + return; + } + if fs::create_dir_all(dir).is_err() { + return; + } + for (name, content) in files { + let _ = fs::write(dir.join(name), content); + } + log::info!("{log_msg}"); +} + /// Seed the `_themes/` directory with built-in themes if it doesn't exist yet. /// Safe to call multiple times — only writes files that are missing. pub fn seed_default_themes(vault_path: &str) { - let themes_dir = Path::new(vault_path).join("_themes"); - if themes_dir.is_dir() { - return; + seed_dir_with_files( + &Path::new(vault_path).join("_themes"), + &[("default.json", DEFAULT_THEME), ("dark.json", DARK_THEME), ("minimal.json", MINIMAL_THEME)], + "Seeded _themes/ with built-in themes", + ); +} + +/// Seed the vault `theme/` directory with built-in vault-based theme notes. +/// Safe to call multiple times — only writes files that are missing. +pub fn seed_vault_themes(vault_path: &str) { + seed_dir_with_files( + &Path::new(vault_path).join("theme"), + &[("default.md", DEFAULT_VAULT_THEME), ("dark.md", DARK_VAULT_THEME), ("minimal.md", MINIMAL_VAULT_THEME)], + "Seeded theme/ with built-in vault themes", + ); +} + +/// Create a new vault theme note in `theme/` directory. +/// Returns the absolute path to the newly created theme note. +pub fn create_vault_theme(vault_path: &str, name: Option<&str>) -> Result { + let theme_dir = Path::new(vault_path).join("theme"); + fs::create_dir_all(&theme_dir) + .map_err(|e| format!("Failed to create theme directory: {e}"))?; + + let display_name = name.unwrap_or("Untitled Theme"); + let slug = slugify(display_name); + let filename = format!("{}.md", find_available_stem(&theme_dir, &slug, "md")); + let path = theme_dir.join(&filename); + + let content = vault_theme_note_content(display_name, &DEFAULT_VAULT_THEME_VARS); + fs::write(&path, content) + .map_err(|e| format!("Failed to write theme note: {e}"))?; + + Ok(path.to_string_lossy().to_string()) +} + +/// Convert a display name to a URL-safe slug. +fn slugify(name: &str) -> String { + name.chars() + .map(|c| if c.is_ascii_alphanumeric() { c.to_ascii_lowercase() } else { '-' }) + .collect::() + .split('-') + .filter(|s| !s.is_empty()) + .collect::>() + .join("-") +} + +/// Find an available filename stem (base, base-2, base-3, …) that doesn't conflict when `ext` is appended. +fn find_available_stem(dir: &Path, base: &str, ext: &str) -> String { + if !dir.join(format!("{base}.{ext}")).exists() { + return base.to_string(); } - if fs::create_dir_all(&themes_dir).is_err() { - return; + for i in 2.. { + let candidate = format!("{base}-{i}"); + if !dir.join(format!("{candidate}.{ext}")).exists() { + return candidate; + } } - let _ = fs::write(themes_dir.join("default.json"), DEFAULT_THEME); - let _ = fs::write(themes_dir.join("dark.json"), DARK_THEME); - let _ = fs::write(themes_dir.join("minimal.json"), MINIMAL_THEME); - log::info!("Seeded _themes/ with built-in themes"); + unreachable!() +} + +/// Build a vault theme note markdown string from a name and CSS variable map. +fn vault_theme_note_content(name: &str, vars: &[(&str, &str)]) -> String { + let mut fm = format!("---\nIs A: Theme\nDescription: {name} theme\n"); + for (key, value) in vars { + // Values with '#' or spaces need quoting; others can be bare strings. + if value.contains('#') || value.contains('\'') || value.contains(',') { + fm.push_str(&format!("{key}: \"{value}\"\n")); + } else { + fm.push_str(&format!("{key}: {value}\n")); + } + } + fm.push_str("---\n\n"); + fm.push_str(&format!("# {name} Theme\n\nA custom {name} theme for Laputa.\n")); + fm } /// Create a new theme file by copying the active theme (or default). @@ -139,7 +217,7 @@ pub fn create_theme(vault_path: &str, source_id: Option<&str>) -> Result) -> Result String { - if !dir.join(format!("{base}.json")).exists() { - return base.to_string(); - } - for i in 2.. { - let candidate = format!("{base}-{i}"); - if !dir.join(format!("{candidate}.json")).exists() { - return candidate; - } - } - unreachable!() -} - /// Generate the default light theme JSON. fn default_theme_json(name: &str) -> String { serde_json::to_string_pretty(&serde_json::json!({ @@ -312,6 +376,230 @@ pub const MINIMAL_THEME: &str = r##"{ } }"##; +/// CSS variable key-value pairs for the default light vault theme. +pub const DEFAULT_VAULT_THEME_VARS: [(&str, &str); 46] = [ + // shadcn/ui base + ("background", "#FFFFFF"), + ("foreground", "#37352F"), + ("card", "#FFFFFF"), + ("popover", "#FFFFFF"), + ("primary", "#155DFF"), + ("primary-foreground", "#FFFFFF"), + ("secondary", "#EBEBEA"), + ("secondary-foreground", "#37352F"), + ("muted", "#F0F0EF"), + ("muted-foreground", "#787774"), + ("accent", "#EBEBEA"), + ("accent-foreground", "#37352F"), + ("destructive", "#E03E3E"), + ("border", "#E9E9E7"), + ("input", "#E9E9E7"), + ("ring", "#155DFF"), + ("sidebar", "#F7F6F3"), + ("sidebar-foreground", "#37352F"), + ("sidebar-border", "#E9E9E7"), + ("sidebar-accent", "#EBEBEA"), + // Text hierarchy + ("text-primary", "#37352F"), + ("text-secondary", "#787774"), + ("text-muted", "#B4B4B4"), + ("text-heading", "#37352F"), + // Backgrounds + ("bg-primary", "#FFFFFF"), + ("bg-sidebar", "#F7F6F3"), + ("bg-hover", "#EBEBEA"), + ("bg-hover-subtle", "#F0F0EF"), + ("bg-selected", "#E8F4FE"), + ("border-primary", "#E9E9E7"), + // Accent colours + ("accent-blue", "#155DFF"), + ("accent-green", "#00B38B"), + ("accent-orange", "#D9730D"), + ("accent-red", "#E03E3E"), + ("accent-purple", "#A932FF"), + ("accent-yellow", "#F0B100"), + ("accent-blue-light", "#155DFF14"), + ("accent-green-light", "#00B38B14"), + ("accent-purple-light", "#A932FF14"), + ("accent-red-light", "#E03E3E14"), + ("accent-yellow-light", "#F0B10014"), + // Typography + ("font-family", "'Inter', -apple-system, BlinkMacSystemFont, sans-serif"), + ("font-size-base", "14px"), + // Editor + ("editor-font-size", "16"), + ("editor-line-height", "1.5"), + ("editor-max-width", "720"), +]; + +/// Vault-based theme note for the built-in Default theme. +pub const DEFAULT_VAULT_THEME: &str = "---\n\ +Is A: Theme\n\ +Description: Light theme with warm, paper-like tones\n\ +background: \"#FFFFFF\"\n\ +foreground: \"#37352F\"\n\ +card: \"#FFFFFF\"\n\ +popover: \"#FFFFFF\"\n\ +primary: \"#155DFF\"\n\ +primary-foreground: \"#FFFFFF\"\n\ +secondary: \"#EBEBEA\"\n\ +secondary-foreground: \"#37352F\"\n\ +muted: \"#F0F0EF\"\n\ +muted-foreground: \"#787774\"\n\ +accent: \"#EBEBEA\"\n\ +accent-foreground: \"#37352F\"\n\ +destructive: \"#E03E3E\"\n\ +border: \"#E9E9E7\"\n\ +input: \"#E9E9E7\"\n\ +ring: \"#155DFF\"\n\ +sidebar: \"#F7F6F3\"\n\ +sidebar-foreground: \"#37352F\"\n\ +sidebar-border: \"#E9E9E7\"\n\ +sidebar-accent: \"#EBEBEA\"\n\ +text-primary: \"#37352F\"\n\ +text-secondary: \"#787774\"\n\ +text-muted: \"#B4B4B4\"\n\ +text-heading: \"#37352F\"\n\ +bg-primary: \"#FFFFFF\"\n\ +bg-sidebar: \"#F7F6F3\"\n\ +bg-hover: \"#EBEBEA\"\n\ +bg-hover-subtle: \"#F0F0EF\"\n\ +bg-selected: \"#E8F4FE\"\n\ +border-primary: \"#E9E9E7\"\n\ +accent-blue: \"#155DFF\"\n\ +accent-green: \"#00B38B\"\n\ +accent-orange: \"#D9730D\"\n\ +accent-red: \"#E03E3E\"\n\ +accent-purple: \"#A932FF\"\n\ +accent-yellow: \"#F0B100\"\n\ +accent-blue-light: \"#155DFF14\"\n\ +accent-green-light: \"#00B38B14\"\n\ +accent-purple-light: \"#A932FF14\"\n\ +accent-red-light: \"#E03E3E14\"\n\ +accent-yellow-light: \"#F0B10014\"\n\ +font-family: \"'Inter', -apple-system, BlinkMacSystemFont, sans-serif\"\n\ +font-size-base: 14px\n\ +editor-font-size: 16\n\ +editor-line-height: 1.5\n\ +editor-max-width: 720\n\ +---\n\ +\n\ +# Default Theme\n\ +\n\ +The default light theme for Laputa. Clean and warm, inspired by Notion.\n"; + +/// Vault-based theme note for the built-in Dark theme. +pub const DARK_VAULT_THEME: &str = "---\n\ +Is A: Theme\n\ +Description: Dark variant with deep navy tones\n\ +background: \"#0f0f1a\"\n\ +foreground: \"#e0e0e0\"\n\ +card: \"#16162a\"\n\ +popover: \"#1e1e3a\"\n\ +primary: \"#155DFF\"\n\ +primary-foreground: \"#FFFFFF\"\n\ +secondary: \"#2a2a4a\"\n\ +secondary-foreground: \"#e0e0e0\"\n\ +muted: \"#1e1e3a\"\n\ +muted-foreground: \"#888888\"\n\ +accent: \"#2a2a4a\"\n\ +accent-foreground: \"#e0e0e0\"\n\ +destructive: \"#f44336\"\n\ +border: \"#2a2a4a\"\n\ +input: \"#2a2a4a\"\n\ +ring: \"#155DFF\"\n\ +sidebar: \"#1a1a2e\"\n\ +sidebar-foreground: \"#e0e0e0\"\n\ +sidebar-border: \"#2a2a4a\"\n\ +sidebar-accent: \"#2a2a4a\"\n\ +text-primary: \"#e0e0e0\"\n\ +text-secondary: \"#888888\"\n\ +text-muted: \"#666666\"\n\ +text-heading: \"#e0e0e0\"\n\ +bg-primary: \"#0f0f1a\"\n\ +bg-sidebar: \"#1a1a2e\"\n\ +bg-hover: \"#2a2a4a\"\n\ +bg-hover-subtle: \"#1e1e3a\"\n\ +bg-selected: \"#155DFF22\"\n\ +border-primary: \"#2a2a4a\"\n\ +accent-blue: \"#155DFF\"\n\ +accent-green: \"#00B38B\"\n\ +accent-orange: \"#D9730D\"\n\ +accent-red: \"#f44336\"\n\ +accent-purple: \"#A932FF\"\n\ +accent-yellow: \"#F0B100\"\n\ +accent-blue-light: \"#155DFF33\"\n\ +accent-green-light: \"#00B38B33\"\n\ +accent-purple-light: \"#A932FF33\"\n\ +accent-red-light: \"#f4433633\"\n\ +accent-yellow-light: \"#F0B10033\"\n\ +font-family: \"'Inter', -apple-system, BlinkMacSystemFont, sans-serif\"\n\ +font-size-base: 14px\n\ +editor-font-size: 16\n\ +editor-line-height: 1.5\n\ +editor-max-width: 720\n\ +---\n\ +\n\ +# Dark Theme\n\ +\n\ +A dark theme with deep navy tones for comfortable night-time reading.\n"; + +/// Vault-based theme note for the built-in Minimal theme. +pub const MINIMAL_VAULT_THEME: &str = "---\n\ +Is A: Theme\n\ +Description: High contrast, minimal chrome\n\ +background: \"#FAFAFA\"\n\ +foreground: \"#111111\"\n\ +card: \"#FFFFFF\"\n\ +popover: \"#FFFFFF\"\n\ +primary: \"#000000\"\n\ +primary-foreground: \"#FFFFFF\"\n\ +secondary: \"#F0F0F0\"\n\ +secondary-foreground: \"#111111\"\n\ +muted: \"#F5F5F5\"\n\ +muted-foreground: \"#666666\"\n\ +accent: \"#F0F0F0\"\n\ +accent-foreground: \"#111111\"\n\ +destructive: \"#CC0000\"\n\ +border: \"#E0E0E0\"\n\ +input: \"#E0E0E0\"\n\ +ring: \"#000000\"\n\ +sidebar: \"#F5F5F5\"\n\ +sidebar-foreground: \"#111111\"\n\ +sidebar-border: \"#E0E0E0\"\n\ +sidebar-accent: \"#E8E8E8\"\n\ +text-primary: \"#111111\"\n\ +text-secondary: \"#666666\"\n\ +text-muted: \"#999999\"\n\ +text-heading: \"#111111\"\n\ +bg-primary: \"#FAFAFA\"\n\ +bg-sidebar: \"#F5F5F5\"\n\ +bg-hover: \"#EBEBEB\"\n\ +bg-hover-subtle: \"#F5F5F5\"\n\ +bg-selected: \"#00000014\"\n\ +border-primary: \"#E0E0E0\"\n\ +accent-blue: \"#000000\"\n\ +accent-green: \"#006600\"\n\ +accent-orange: \"#996600\"\n\ +accent-red: \"#CC0000\"\n\ +accent-purple: \"#660099\"\n\ +accent-yellow: \"#996600\"\n\ +accent-blue-light: \"#00000014\"\n\ +accent-green-light: \"#00660014\"\n\ +accent-purple-light: \"#66009914\"\n\ +accent-red-light: \"#CC000014\"\n\ +accent-yellow-light: \"#99660014\"\n\ +font-family: \"'SF Mono', 'Menlo', monospace\"\n\ +font-size-base: 13px\n\ +editor-font-size: 15\n\ +editor-line-height: 1.6\n\ +editor-max-width: 680\n\ +---\n\ +\n\ +# Minimal Theme\n\ +\n\ +High contrast, minimal chrome. Monospace typography throughout.\n"; + #[cfg(test)] mod tests { use super::*; @@ -462,4 +750,88 @@ mod tests { let themes = list_themes(&vault).unwrap(); assert_eq!(themes.len(), 2); // broken.json is skipped } + + #[test] + fn test_seed_vault_themes_creates_theme_dir() { + let dir = TempDir::new().unwrap(); + let vault = dir.path().join("vault"); + fs::create_dir_all(&vault).unwrap(); + let vp = vault.to_str().unwrap(); + + assert!(!vault.join("theme").exists()); + seed_vault_themes(vp); + assert!(vault.join("theme").is_dir()); + assert!(vault.join("theme").join("default.md").exists()); + assert!(vault.join("theme").join("dark.md").exists()); + assert!(vault.join("theme").join("minimal.md").exists()); + } + + #[test] + fn test_seed_vault_themes_is_idempotent() { + let dir = TempDir::new().unwrap(); + let vault = dir.path().join("vault"); + fs::create_dir_all(&vault).unwrap(); + let vp = vault.to_str().unwrap(); + + seed_vault_themes(vp); + seed_vault_themes(vp); // second call should be a no-op + assert!(vault.join("theme").join("default.md").exists()); + } + + #[test] + fn test_create_vault_theme_creates_md_file() { + let dir = TempDir::new().unwrap(); + let vault = dir.path().join("vault"); + fs::create_dir_all(&vault).unwrap(); + let vp = vault.to_str().unwrap(); + + let path = create_vault_theme(vp, Some("My Theme")).unwrap(); + assert!(std::path::Path::new(&path).exists()); + let content = fs::read_to_string(&path).unwrap(); + assert!(content.contains("Is A: Theme")); + assert!(content.contains("# My Theme")); + assert!(content.contains("background:")); + } + + #[test] + fn test_create_vault_theme_default_name() { + let dir = TempDir::new().unwrap(); + let vault = dir.path().join("vault"); + fs::create_dir_all(&vault).unwrap(); + let vp = vault.to_str().unwrap(); + + let path = create_vault_theme(vp, None).unwrap(); + let content = fs::read_to_string(&path).unwrap(); + assert!(content.contains("# Untitled Theme")); + } + + #[test] + fn test_create_vault_theme_avoids_conflicts() { + let dir = TempDir::new().unwrap(); + let vault = dir.path().join("vault"); + fs::create_dir_all(&vault).unwrap(); + let vp = vault.to_str().unwrap(); + + let p1 = create_vault_theme(vp, Some("Custom")).unwrap(); + let p2 = create_vault_theme(vp, Some("Custom")).unwrap(); + assert_ne!(p1, p2); + } + + #[test] + fn test_slugify() { + assert_eq!(slugify("My Cool Theme"), "my-cool-theme"); + assert_eq!(slugify("default"), "default"); + assert_eq!(slugify("Dark Mode!"), "dark-mode"); + } + + #[test] + fn test_vault_theme_content_contains_all_vars() { + let content = DEFAULT_VAULT_THEME; + assert!(content.contains("background:")); + assert!(content.contains("primary:")); + assert!(content.contains("sidebar:")); + assert!(content.contains("text-primary:")); + assert!(content.contains("accent-blue:")); + assert!(content.contains("editor-font-size:")); + } } diff --git a/src/App.tsx b/src/App.tsx index 8408591b..b04e7ea8 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -123,7 +123,7 @@ function App() { const resolvedPath = onboarding.state.status === 'ready' ? onboarding.state.vaultPath : vaultSwitcher.vaultPath const vault = useVaultLoader(resolvedPath) const { settings, saveSettings } = useSettings() - const themeManager = useThemeManager(resolvedPath) + const themeManager = useThemeManager(resolvedPath, vault.entries, vault.allContent) useMcpRegistration(resolvedPath, setToastMessage) @@ -303,7 +303,15 @@ function App() { canGoBack: navHistory.canGoBack, canGoForward: navHistory.canGoForward, themes: themeManager.themes, activeThemeId: themeManager.activeThemeId, onSwitchTheme: themeManager.switchTheme, - onCreateTheme: async () => { await themeManager.createTheme() }, + onCreateTheme: async () => { + await themeManager.createTheme() + await vault.reloadVault() + setSelection({ kind: 'sectionGroup', type: 'Theme' }) + }, + onOpenTheme: (themeId: string) => { + const entry = vault.entries.find(e => e.path === themeId) + if (entry) notes.handleSelectNote(entry) + }, onOpenVault: vaultSwitcher.handleOpenLocalFolder, onToggleAIChat: dialogs.toggleAIChat, }) diff --git a/src/components/Sidebar.tsx b/src/components/Sidebar.tsx index 7294386b..0830cca1 100644 --- a/src/components/Sidebar.tsx +++ b/src/components/Sidebar.tsx @@ -15,7 +15,7 @@ import { import { CSS } from '@dnd-kit/utilities' import { FileText, Star, Wrench, Flask, Target, ArrowsClockwise, - Users, CalendarBlank, Tag, TagSimple, Trash, StackSimple, Archive, CaretLeft, GitDiff, + Users, CalendarBlank, Tag, TagSimple, Trash, StackSimple, Archive, CaretLeft, GitDiff, PaintBrush, } from '@phosphor-icons/react' import { GitCommitHorizontal, SlidersHorizontal } from 'lucide-react' import { @@ -48,6 +48,7 @@ const BUILT_IN_SECTION_GROUPS: SectionGroup[] = [ { label: 'Events', type: 'Event', Icon: CalendarBlank }, { label: 'Topics', type: 'Topic', Icon: Tag }, { label: 'Types', type: 'Type', Icon: StackSimple }, + { label: 'Themes', type: 'Theme', Icon: PaintBrush }, ] /** Metadata lookup for well-known types (icon/label only — NOT used to determine which sections to show) */ diff --git a/src/hooks/useAppCommands.ts b/src/hooks/useAppCommands.ts index 49ea7548..3ecbb77d 100644 --- a/src/hooks/useAppCommands.ts +++ b/src/hooks/useAppCommands.ts @@ -51,6 +51,7 @@ interface AppCommandsConfig { activeThemeId?: string | null onSwitchTheme?: (themeId: string) => void onCreateTheme?: () => void + onOpenTheme?: (themeId: string) => void onOpenVault?: () => void onToggleAIChat?: () => void } @@ -147,6 +148,7 @@ export function useAppCommands(config: AppCommandsConfig): CommandAction[] { activeThemeId: config.activeThemeId, onSwitchTheme: config.onSwitchTheme, onCreateTheme: config.onCreateTheme, + onOpenTheme: config.onOpenTheme, onOpenVault: config.onOpenVault, onToggleAIChat: config.onToggleAIChat, }) diff --git a/src/hooks/useCommandRegistry.ts b/src/hooks/useCommandRegistry.ts index fc3f8d26..60f26c3e 100644 --- a/src/hooks/useCommandRegistry.ts +++ b/src/hooks/useCommandRegistry.ts @@ -49,6 +49,7 @@ interface CommandRegistryConfig { activeThemeId?: string | null onSwitchTheme?: (themeId: string) => void onCreateTheme?: () => void + onOpenTheme?: (themeId: string) => void } const PLURAL_OVERRIDES: Record = { @@ -132,22 +133,36 @@ export function buildThemeCommands( activeThemeId: string | null | undefined, onSwitchTheme: ((themeId: string) => void) | undefined, onCreateTheme: (() => void) | undefined, + onOpenTheme: ((themeId: string) => void) | undefined, ): CommandAction[] { - const switchCmds = (themes ?? []).map(t => ({ - id: `switch-theme-${t.id}`, - label: `Switch to ${t.name} Theme`, - group: 'Appearance' as CommandGroup, - keywords: ['theme', 'appearance', 'color', t.name.toLowerCase()], - enabled: t.id !== activeThemeId, - execute: () => onSwitchTheme?.(t.id), - })) + const cmds: CommandAction[] = [] + for (const t of (themes ?? [])) { + cmds.push({ + id: `switch-theme-${t.id}`, + label: `Switch to ${t.name} Theme`, + group: 'Appearance' as CommandGroup, + keywords: ['theme', 'appearance', 'color', t.name.toLowerCase()], + enabled: t.id !== activeThemeId, + execute: () => onSwitchTheme?.(t.id), + }) + if (onOpenTheme) { + cmds.push({ + id: `open-theme-${t.id}`, + label: `Edit ${t.name} Theme`, + group: 'Appearance' as CommandGroup, + keywords: ['theme', 'edit', 'open', 'appearance', t.name.toLowerCase()], + enabled: true, + execute: () => onOpenTheme(t.id), + }) + } + } if (onCreateTheme) { - switchCmds.push({ + cmds.push({ id: 'new-theme', label: 'New Theme', group: 'Appearance' as CommandGroup, keywords: ['theme', 'create', 'appearance'], enabled: true, execute: onCreateTheme, }) } - return switchCmds + return cmds } export function useCommandRegistry(config: CommandRegistryConfig): CommandAction[] { @@ -159,7 +174,7 @@ export function useCommandRegistry(config: CommandRegistryConfig): CommandAction onZoomIn, onZoomOut, onZoomReset, zoomLevel, onSelect, onOpenDailyNote, onCloseTab, onGoBack, onGoForward, canGoBack, canGoForward, - themes, activeThemeId, onSwitchTheme, onCreateTheme, + themes, activeThemeId, onSwitchTheme, onCreateTheme, onOpenTheme, } = config const hasActiveNote = activeTabPath !== null @@ -209,7 +224,7 @@ export function useCommandRegistry(config: CommandRegistryConfig): CommandAction ...buildViewCommands(hasActiveNote, onSetViewMode, onToggleInspector, onToggleRawEditor, onToggleAIChat, zoomLevel, onZoomIn, onZoomOut, onZoomReset), // Appearance - ...buildThemeCommands(themes, activeThemeId, onSwitchTheme, onCreateTheme), + ...buildThemeCommands(themes, activeThemeId, onSwitchTheme, onCreateTheme, onOpenTheme), // Settings { id: 'open-settings', label: 'Open Settings', group: 'Settings', shortcut: '⌘,', keywords: ['preferences', 'config'], enabled: true, execute: onOpenSettings }, @@ -228,6 +243,6 @@ export function useCommandRegistry(config: CommandRegistryConfig): CommandAction onZoomIn, onZoomOut, onZoomReset, zoomLevel, onSelect, onOpenDailyNote, onCloseTab, onGoBack, onGoForward, canGoBack, canGoForward, - vaultTypes, themes, activeThemeId, onSwitchTheme, onCreateTheme, onOpenVault, + vaultTypes, themes, activeThemeId, onSwitchTheme, onCreateTheme, onOpenTheme, ]) } diff --git a/src/hooks/useThemeManager.test.ts b/src/hooks/useThemeManager.test.ts index 2670f71a..1658203b 100644 --- a/src/hooks/useThemeManager.test.ts +++ b/src/hooks/useThemeManager.test.ts @@ -1,41 +1,89 @@ import { describe, it, expect, vi, beforeEach } from 'vitest' import { renderHook, act, waitFor } from '@testing-library/react' -import type { ThemeFile, VaultSettings } from '../types' +import type { VaultEntry } from '../types' -const mockThemes: ThemeFile[] = [ - { - id: 'default', name: 'Default', description: 'Clean default theme', - colors: { background: '#FFFFFF', foreground: '#1A1A2E', primary: '#6366F1', border: '#E2E8F0', 'sidebar-background': '#F8FAFC' }, - typography: { 'font-family': 'Inter, sans-serif' }, - spacing: { 'sidebar-width': '240px' }, - }, - { - id: 'dark', name: 'Dark', description: 'Dark theme', - colors: { background: '#0F0F23', foreground: '#E2E8F0', primary: '#818CF8', border: '#1E293B' }, - typography: { 'font-family': 'Inter, sans-serif' }, - spacing: {}, - }, -] +const THEME_PATH_DEFAULT = '/vault/theme/default.md' +const THEME_PATH_DARK = '/vault/theme/dark.md' -const mockSettings: VaultSettings = { theme: 'default' } +const DEFAULT_THEME_CONTENT = `--- +Is A: Theme +Description: Light theme +background: "#FFFFFF" +foreground: "#37352F" +primary: "#155DFF" +sidebar: "#F7F6F3" +text-primary: "#37352F" +--- -const mockInvokeFn = vi.fn(async (cmd: string) => { - if (cmd === 'list_themes') return mockThemes - if (cmd === 'get_vault_settings') return mockSettings +# Default Theme +` + +const DARK_THEME_CONTENT = `--- +Is A: Theme +Description: Dark theme +background: "#0f0f1a" +foreground: "#e0e0e0" +primary: "#155DFF" +sidebar: "#1a1a2e" +text-primary: "#e0e0e0" +--- + +# Dark Theme +` + +function makeThemeEntry(path: string, title: string): VaultEntry { + return { + path, + filename: path.split('/').pop()!, + title, + isA: 'Theme', + aliases: [], + belongsTo: [], + relatedTo: [], + status: null, + owner: null, + cadence: null, + archived: false, + trashed: false, + trashedAt: null, + modifiedAt: null, + createdAt: null, + fileSize: 0, + snippet: '', + wordCount: 0, + relationships: {}, + icon: null, + color: null, + order: null, + sidebarLabel: null, + template: null, + outgoingLinks: [], + } +} + +const defaultEntry = makeThemeEntry(THEME_PATH_DEFAULT, 'Default Theme') +const darkEntry = makeThemeEntry(THEME_PATH_DARK, 'Dark Theme') + +const mockInvokeFn = vi.fn(async (cmd: string, args?: Record) => { + if (cmd === 'get_vault_settings') return { theme: THEME_PATH_DEFAULT } + if (cmd === 'get_note_content') { + const path = args?.path as string | undefined + if (path === THEME_PATH_DEFAULT) return DEFAULT_THEME_CONTENT + if (path === THEME_PATH_DARK) return DARK_THEME_CONTENT + return '' + } if (cmd === 'set_active_theme') return null - if (cmd === 'create_theme') return 'new-theme-id' + if (cmd === 'create_vault_theme') return '/vault/theme/untitled.md' return null }) -vi.mock('@tauri-apps/api/core', () => ({ - invoke: vi.fn(), -})) - +vi.mock('@tauri-apps/api/core', () => ({ invoke: vi.fn() })) vi.mock('../mock-tauri', () => ({ isTauri: () => false, mockInvoke: (cmd: string, args?: Record) => mockInvokeFn(cmd, args), })) +<<<<<<< HEAD // Must import after mocks const { useThemeManager, isColorDark } = await import('./useThemeManager') @@ -53,196 +101,237 @@ describe('isColorDark', () => { expect(isColorDark('#E0E0E0')).toBe(false) }) }) +======= +const { useThemeManager, extractCssVars } = await import('./useThemeManager') +>>>>>>> 240be0d (wip: themes-editable — theme management system WIP (13 files, 1014 insertions)) -describe('useThemeManager', () => { - beforeEach(() => { - vi.clearAllMocks() - mockInvokeFn.mockImplementation(async (cmd: string) => { - if (cmd === 'list_themes') return mockThemes - if (cmd === 'get_vault_settings') return mockSettings - if (cmd === 'set_active_theme') return null - if (cmd === 'create_theme') return 'new-theme-id' - return null - }) - // Clear any theme CSS properties from previous tests - const root = document.documentElement - root.style.cssText = '' +describe('extractCssVars', () => { + it('extracts color variables from frontmatter', () => { + const vars = extractCssVars(DEFAULT_THEME_CONTENT) + expect(vars['--background']).toBe('#FFFFFF') + expect(vars['--foreground']).toBe('#37352F') + expect(vars['--primary']).toBe('#155DFF') }) - it('loads themes and active theme on mount', async () => { - const { result } = renderHook(() => useThemeManager('/vault')) + it('excludes metadata keys', () => { + const vars = extractCssVars(DEFAULT_THEME_CONTENT) + expect('--Is A' in vars).toBe(false) + expect('--Description' in vars).toBe(false) + }) +}) + +describe('useThemeManager', () => { + const entries = [defaultEntry, darkEntry] + const allContent: Record = {} + + beforeEach(() => { + vi.clearAllMocks() + mockInvokeFn.mockImplementation(async (cmd: string, args?: Record) => { + if (cmd === 'get_vault_settings') return { theme: THEME_PATH_DEFAULT } + if (cmd === 'get_note_content') { + const path = args?.path as string | undefined + if (path === THEME_PATH_DEFAULT) return DEFAULT_THEME_CONTENT + if (path === THEME_PATH_DARK) return DARK_THEME_CONTENT + return '' + } + if (cmd === 'set_active_theme') return null + if (cmd === 'create_vault_theme') return '/vault/theme/untitled.md' + return null + }) + document.documentElement.style.cssText = '' + }) + + it('builds themes list from vault entries with isA === Theme', async () => { + const { result } = renderHook(() => + useThemeManager('/vault', entries, allContent) + ) await waitFor(() => { expect(result.current.themes).toHaveLength(2) }) - expect(result.current.activeThemeId).toBe('default') - expect(result.current.activeTheme?.name).toBe('Default') + expect(result.current.themes[0].name).toBe('Default Theme') + expect(result.current.themes[0].id).toBe(THEME_PATH_DEFAULT) + }) + + it('loads active theme from vault settings on mount', async () => { + const { result } = renderHook(() => + useThemeManager('/vault', entries, allContent) + ) + await waitFor(() => { + expect(result.current.activeThemeId).toBe(THEME_PATH_DEFAULT) + }) + expect(result.current.activeTheme?.name).toBe('Default Theme') + }) + + it('applies CSS vars from theme note content', async () => { + const { result } = renderHook(() => + useThemeManager('/vault', entries, allContent) + ) + await waitFor(() => { + expect(result.current.activeTheme).not.toBeNull() + }) + const root = document.documentElement + expect(root.style.getPropertyValue('--background')).toBe('#FFFFFF') + expect(root.style.getPropertyValue('--foreground')).toBe('#37352F') + expect(root.style.getPropertyValue('--primary')).toBe('#155DFF') }) it('returns empty state when vaultPath is null', async () => { - const { result } = renderHook(() => useThemeManager(null)) - // Give it time to settle — should remain empty + const { result } = renderHook(() => + useThemeManager(null, entries, allContent) + ) await new Promise(r => setTimeout(r, 50)) - expect(result.current.themes).toHaveLength(0) expect(result.current.activeThemeId).toBeNull() expect(result.current.activeTheme).toBeNull() expect(mockInvokeFn).not.toHaveBeenCalled() }) - it('applies CSS custom properties for active theme', async () => { - const { result } = renderHook(() => useThemeManager('/vault')) + it('excludes trashed entries from themes list', async () => { + const trashedEntry = { ...darkEntry, trashed: true } + const { result } = renderHook(() => + useThemeManager('/vault', [defaultEntry, trashedEntry], allContent) + ) await waitFor(() => { - expect(result.current.activeTheme).not.toBeNull() + expect(result.current.themes).toHaveLength(1) }) - const root = document.documentElement - expect(root.style.getPropertyValue('--background')).toBe('#FFFFFF') - expect(root.style.getPropertyValue('--foreground')).toBe('#1A1A2E') - expect(root.style.getPropertyValue('--primary')).toBe('#6366F1') - expect(root.style.getPropertyValue('--theme-background')).toBe('#FFFFFF') - expect(root.style.getPropertyValue('--theme-font-family')).toBe('Inter, sans-serif') - expect(root.style.getPropertyValue('--theme-sidebar-width')).toBe('240px') - }) - - it('maps sidebar-background to --sidebar', async () => { - const { result } = renderHook(() => useThemeManager('/vault')) - await waitFor(() => { - expect(result.current.activeTheme).not.toBeNull() - }) - expect(document.documentElement.style.getPropertyValue('--sidebar')).toBe('#F8FAFC') + expect(result.current.themes[0].name).toBe('Default Theme') }) it('switchTheme calls set_active_theme and updates activeThemeId', async () => { - const { result } = renderHook(() => useThemeManager('/vault')) + const { result } = renderHook(() => + useThemeManager('/vault', entries, allContent) + ) + await waitFor(() => { expect(result.current.themes).toHaveLength(2) }) + + await act(async () => { + await result.current.switchTheme(THEME_PATH_DARK) + }) + + expect(mockInvokeFn).toHaveBeenCalledWith('set_active_theme', { + vaultPath: '/vault', themeId: THEME_PATH_DARK, + }) + expect(result.current.activeThemeId).toBe(THEME_PATH_DARK) + }) + + it('clears old CSS vars and applies new theme on switch', async () => { + const { result } = renderHook(() => + useThemeManager('/vault', entries, allContent) + ) await waitFor(() => { - expect(result.current.themes).toHaveLength(2) + expect(document.documentElement.style.getPropertyValue('--background')).toBe('#FFFFFF') }) await act(async () => { - await result.current.switchTheme('dark') + await result.current.switchTheme(THEME_PATH_DARK) }) - expect(mockInvokeFn).toHaveBeenCalledWith('set_active_theme', { vaultPath: '/vault', themeId: 'dark' }) - expect(result.current.activeThemeId).toBe('dark') + await waitFor(() => { + expect(document.documentElement.style.getPropertyValue('--background')).toBe('#0f0f1a') + }) }) - it('clears old theme CSS and applies new theme on switch', async () => { - const { result } = renderHook(() => useThemeManager('/vault')) - await waitFor(() => { - expect(result.current.activeTheme?.id).toBe('default') - }) - - const root = document.documentElement - expect(root.style.getPropertyValue('--background')).toBe('#FFFFFF') + it('createTheme calls create_vault_theme and switches to new theme', async () => { + const { result } = renderHook(() => + useThemeManager('/vault', entries, allContent) + ) + await waitFor(() => { expect(result.current.themes).toHaveLength(2) }) + let newPath = '' await act(async () => { - await result.current.switchTheme('dark') + newPath = await result.current.createTheme('My Theme') }) - await waitFor(() => { - expect(root.style.getPropertyValue('--background')).toBe('#0F0F23') + expect(newPath).toBe('/vault/theme/untitled.md') + expect(mockInvokeFn).toHaveBeenCalledWith('create_vault_theme', { + vaultPath: '/vault', name: 'My Theme', }) - expect(root.style.getPropertyValue('--foreground')).toBe('#E2E8F0') + expect(result.current.activeThemeId).toBe('/vault/theme/untitled.md') }) - it('createTheme calls create_theme and reloads themes', async () => { - const { result } = renderHook(() => useThemeManager('/vault')) - await waitFor(() => { - expect(result.current.themes).toHaveLength(2) - }) - - let newId = '' - await act(async () => { - newId = await result.current.createTheme('default') - }) - - expect(newId).toBe('new-theme-id') - expect(mockInvokeFn).toHaveBeenCalledWith('create_theme', { vaultPath: '/vault', sourceId: 'default' }) - // Should reload after creation - const listCalls = mockInvokeFn.mock.calls.filter(c => c[0] === 'list_themes') - expect(listCalls.length).toBeGreaterThanOrEqual(2) - }) - - it('createTheme passes null source_id when no sourceId provided', async () => { - const { result } = renderHook(() => useThemeManager('/vault')) - await waitFor(() => { - expect(result.current.themes).toHaveLength(2) - }) + it('createTheme passes null name when none provided', async () => { + const { result } = renderHook(() => + useThemeManager('/vault', entries, allContent) + ) + await waitFor(() => { expect(result.current.themes).toHaveLength(2) }) await act(async () => { await result.current.createTheme() }) - expect(mockInvokeFn).toHaveBeenCalledWith('create_theme', { vaultPath: '/vault', sourceId: null }) + expect(mockInvokeFn).toHaveBeenCalledWith('create_vault_theme', { + vaultPath: '/vault', name: null, + }) + }) + + it('falls back when active theme is trashed', async () => { + const { result, rerender } = renderHook( + ({ ents }) => useThemeManager('/vault', ents, allContent), + { initialProps: { ents: entries } }, + ) + await waitFor(() => { + expect(result.current.activeThemeId).toBe(THEME_PATH_DEFAULT) + }) + + const trashedDefault = { ...defaultEntry, trashed: true } + rerender({ ents: [trashedDefault, darkEntry] }) + + await waitFor(() => { + expect(result.current.activeThemeId).toBeNull() + }) + expect(document.documentElement.style.getPropertyValue('--background')).toBe('') }) it('handles load failure gracefully', async () => { const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) mockInvokeFn.mockRejectedValue(new Error('disk error')) - const { result } = renderHook(() => useThemeManager('/vault')) - await waitFor(() => { - expect(warnSpy).toHaveBeenCalledWith('Failed to load themes:', expect.any(Error)) - }) + const { result } = renderHook(() => + useThemeManager('/vault', entries, allContent) + ) + await new Promise(r => setTimeout(r, 50)) - expect(result.current.themes).toHaveLength(0) expect(result.current.activeThemeId).toBeNull() warnSpy.mockRestore() }) it('handles switchTheme failure gracefully', async () => { const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) - const { result } = renderHook(() => useThemeManager('/vault')) - await waitFor(() => { - expect(result.current.themes).toHaveLength(2) - }) + const { result } = renderHook(() => + useThemeManager('/vault', entries, allContent) + ) + await waitFor(() => { expect(result.current.themes).toHaveLength(2) }) mockInvokeFn.mockRejectedValueOnce(new Error('permission denied')) await act(async () => { - await result.current.switchTheme('dark') + await result.current.switchTheme(THEME_PATH_DARK) }) - // Should not have changed the active theme - expect(result.current.activeThemeId).toBe('default') - expect(errorSpy).toHaveBeenCalledWith('Failed to switch theme:', expect.any(Error)) - errorSpy.mockRestore() - }) - - it('handles createTheme failure gracefully', async () => { - const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) - const { result } = renderHook(() => useThemeManager('/vault')) - await waitFor(() => { - expect(result.current.themes).toHaveLength(2) - }) - - mockInvokeFn.mockRejectedValueOnce(new Error('write error')) - - let newId = '' - await act(async () => { - newId = await result.current.createTheme('default') - }) - - expect(newId).toBe('') - expect(errorSpy).toHaveBeenCalledWith('Failed to create theme:', expect.any(Error)) + expect(result.current.activeThemeId).toBe(THEME_PATH_DEFAULT) errorSpy.mockRestore() }) it('switchTheme is a no-op when vaultPath is null', async () => { - const { result } = renderHook(() => useThemeManager(null)) + const { result } = renderHook(() => + useThemeManager(null, entries, allContent) + ) await act(async () => { - await result.current.switchTheme('dark') + await result.current.switchTheme(THEME_PATH_DARK) }) expect(mockInvokeFn).not.toHaveBeenCalledWith('set_active_theme', expect.anything()) }) it('createTheme returns empty string when vaultPath is null', async () => { - const { result } = renderHook(() => useThemeManager(null)) - let newId = '' + const { result } = renderHook(() => + useThemeManager(null, entries, allContent) + ) + let newPath = '' await act(async () => { - newId = await result.current.createTheme() + newPath = await result.current.createTheme() }) - expect(newId).toBe('') + expect(newPath).toBe('') }) +<<<<<<< HEAD it('isDark is false for light theme', async () => { const { result } = renderHook(() => useThemeManager('/vault')) await waitFor(() => { @@ -340,17 +429,40 @@ describe('useThemeManager', () => { it('reloadThemes re-fetches theme list', async () => { const { result } = renderHook(() => useThemeManager('/vault')) +======= + it('re-applies theme when active content changes in allContent', async () => { + const { result, rerender } = renderHook( + ({ content }) => useThemeManager('/vault', entries, content), + { initialProps: { content: allContent } }, + ) +>>>>>>> 240be0d (wip: themes-editable — theme management system WIP (13 files, 1014 insertions)) await waitFor(() => { - expect(result.current.themes).toHaveLength(2) + expect(result.current.activeTheme).not.toBeNull() }) - const initialListCalls = mockInvokeFn.mock.calls.filter(c => c[0] === 'list_themes').length + const newContent = { + [THEME_PATH_DEFAULT]: `---\nIs A: Theme\nbackground: "#FF0000"\n---\n# Default Theme\n`, + } + rerender({ content: newContent }) + + await waitFor(() => { + expect(document.documentElement.style.getPropertyValue('--background')).toBe('#FF0000') + }) + }) + + it('reloadThemes re-reads vault settings', async () => { + const { result } = renderHook(() => + useThemeManager('/vault', entries, allContent) + ) + await waitFor(() => { expect(result.current.themes).toHaveLength(2) }) + + const initialCalls = mockInvokeFn.mock.calls.filter(c => c[0] === 'get_vault_settings').length await act(async () => { await result.current.reloadThemes() }) - const afterListCalls = mockInvokeFn.mock.calls.filter(c => c[0] === 'list_themes').length - expect(afterListCalls).toBe(initialListCalls + 1) + const afterCalls = mockInvokeFn.mock.calls.filter(c => c[0] === 'get_vault_settings').length + expect(afterCalls).toBe(initialCalls + 1) }) }) diff --git a/src/hooks/useThemeManager.ts b/src/hooks/useThemeManager.ts index 51f77654..95d3d2e3 100644 --- a/src/hooks/useThemeManager.ts +++ b/src/hooks/useThemeManager.ts @@ -1,12 +1,14 @@ -import { useCallback, useEffect, useRef, useState } from 'react' +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { invoke } from '@tauri-apps/api/core' import { isTauri, mockInvoke } from '../mock-tauri' -import type { ThemeFile, VaultSettings } from '../types' +import { parseFrontmatter } from '../utils/frontmatter' +import type { ThemeFile, VaultEntry, VaultSettings } from '../types' function tauriCall(command: string, args: Record): Promise { return isTauri() ? invoke(command, args) : mockInvoke(command, args) } +<<<<<<< HEAD // --- Color utilities for theme variable derivation --- function parseHex(hex: string): [number, number, number] { @@ -121,37 +123,80 @@ function clearDerivedVariables(root: HTMLElement): void { /** Map theme colors/typography/spacing to CSS custom properties on :root. */ function applyThemeToDom(theme: ThemeFile): void { +======= +/** Frontmatter keys that are metadata — not CSS custom properties. */ +const NON_THEME_KEYS = new Set([ + 'Is A', 'type', 'is_a', 'is a', + 'Name', 'name', 'title', 'Title', + 'Description', 'description', + 'Archived', 'archived', + 'Trashed', 'trashed', + 'Trashed at', 'trashed at', 'trashed_at', + 'Created at', 'created at', 'created_at', + 'Created time', 'created_time', + 'Owner', 'owner', + 'Status', 'status', + 'Cadence', 'cadence', + 'aliases', + 'Belongs to', 'belongs_to', 'belongs to', + 'Related to', 'related_to', 'related to', +]) + +/** Extract CSS custom properties from a theme note's frontmatter content. */ +export function extractCssVars(content: string): Record { + const fm = parseFrontmatter(content) + const vars: Record = {} + for (const [key, value] of Object.entries(fm)) { + if (NON_THEME_KEYS.has(key)) continue + if (typeof value === 'string' && value) { + vars[`--${key}`] = value + } else if (typeof value === 'number') { + vars[`--${key}`] = String(value) + } + } + return vars +} + +function applyVarsToDom(vars: Record): void { +>>>>>>> 240be0d (wip: themes-editable — theme management system WIP (13 files, 1014 insertions)) const root = document.documentElement - for (const [key, value] of Object.entries(theme.colors)) { - root.style.setProperty(`--theme-${key}`, value) - 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) - } - if (theme.colors['sidebar-background']) { - root.style.setProperty('--sidebar', theme.colors['sidebar-background']) + for (const [key, value] of Object.entries(vars)) { + root.style.setProperty(key, value) } deriveThemeVariables(root, theme.colors) } -function clearThemeFromDom(theme: ThemeFile): void { +function clearVarsFromDom(vars: Record): 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(vars)) { + root.style.removeProperty(key) } - for (const key of Object.keys(theme.typography)) { - root.style.removeProperty(`--theme-${key}`) +} + +/** Build a ThemeFile descriptor from a vault entry (metadata only). */ +function entryToThemeFile(entry: VaultEntry): ThemeFile { + return { + id: entry.path, + name: entry.title, + description: '', + path: entry.path, + colors: {}, + typography: {}, + spacing: {}, } +<<<<<<< HEAD for (const key of Object.keys(theme.spacing)) { root.style.removeProperty(`--theme-${key}`) } root.style.removeProperty('--sidebar') clearDerivedVariables(root) +======= +} + +/** True when a theme entry should no longer be applied (trashed or archived). */ +function isEntryRemoved(entry: VaultEntry): boolean { + return entry.trashed || entry.archived +>>>>>>> 240be0d (wip: themes-editable — theme management system WIP (13 files, 1014 insertions)) } export interface ThemeManager { @@ -160,81 +205,125 @@ export interface ThemeManager { activeTheme: ThemeFile | null isDark: boolean switchTheme: (themeId: string) => Promise - createTheme: (sourceId?: string) => Promise + createTheme: (name?: string) => Promise reloadThemes: () => Promise } -/** Sync CSS custom properties: clear old theme, apply new one. */ -function syncThemeDom( - prevRef: React.MutableRefObject, - 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([]) +/** Manages loading and persisting the active theme path from vault settings. */ +function useThemeSetting(vaultPath: string | null) { const [activeThemeId, setActiveThemeId] = useState(null) - const prevThemeRef = useRef(null) +<<<<<<< HEAD const activeTheme = themes.find(t => t.id === activeThemeId) ?? null const isDark = activeTheme?.colors.background ? isColorDark(activeTheme.colors.background) : false const loadThemes = useCallback(async () => { +======= + const load = useCallback(async () => { +>>>>>>> 240be0d (wip: themes-editable — theme management system WIP (13 files, 1014 insertions)) if (!vaultPath) return try { - const [themeList, settings] = await Promise.all([ - tauriCall('list_themes', { vaultPath }), - tauriCall('get_vault_settings', { vaultPath }), - ]) - setThemes(themeList) - setActiveThemeId(settings.theme) - } catch (err) { - console.warn('Failed to load themes:', err) - } + const s = await tauriCall('get_vault_settings', { vaultPath }) + setActiveThemeId(s.theme) + } catch { /* no settings file — fine, no active theme */ } }, [vaultPath]) - useEffect(() => { loadThemes() }, [loadThemes]) // eslint-disable-line react-hooks/set-state-in-effect -- trigger initial load - useEffect(() => { syncThemeDom(prevThemeRef, activeTheme) }, [activeTheme]) + // eslint-disable-next-line react-hooks/set-state-in-effect -- async fn; setState runs after await + useEffect(() => { load() }, [load]) - // Reload themes when window regains focus (live reload for external edits) useEffect(() => { - const onFocus = () => { loadThemes() } - window.addEventListener('focus', onFocus) - return () => window.removeEventListener('focus', onFocus) - }, [loadThemes]) + window.addEventListener('focus', load) + return () => window.removeEventListener('focus', load) + }, [load]) + + return { activeThemeId, setActiveThemeId, reload: load } +} + +/** Applies CSS custom properties to the document root from the active theme. */ +function useThemeApplier( + activeThemeId: string | null, + cachedContent: string | undefined, +) { + const appliedVarsRef = useRef>({}) + + const apply = useCallback((content: string) => { + const newVars = extractCssVars(content) + clearVarsFromDom(appliedVarsRef.current) + applyVarsToDom(newVars) + appliedVarsRef.current = newVars + }, []) + + const clear = useCallback(() => { + clearVarsFromDom(appliedVarsRef.current) + appliedVarsRef.current = {} + }, []) + + // Apply theme when activeThemeId or cached content changes. + // Also serves as live-preview: re-applies when the user saves the theme note. + useEffect(() => { + if (!activeThemeId) { clear(); return } + if (cachedContent) { apply(cachedContent); return } + tauriCall('get_note_content', { path: activeThemeId }) + .then(apply) + .catch(clear) + }, [activeThemeId, cachedContent, apply, clear]) + + return { clear } +} + +export function useThemeManager( + vaultPath: string | null, + entries: VaultEntry[], + allContent: Record, +): ThemeManager { + const { activeThemeId, setActiveThemeId, reload } = useThemeSetting(vaultPath) + const cachedThemeContent = activeThemeId ? allContent[activeThemeId] : undefined + const { clear: clearTheme } = useThemeApplier(activeThemeId, cachedThemeContent) + + const themes = useMemo( + () => entries.filter(e => e.isA === 'Theme' && !e.trashed && !e.archived).map(entryToThemeFile), + [entries], + ) + + const activeTheme = useMemo( + () => themes.find(t => t.id === activeThemeId) ?? null, + [themes, activeThemeId], + ) + + // If active theme is trashed or archived: clear CSS vars and fall back to no theme + useEffect(() => { + if (!activeThemeId) return + const entry = entries.find(e => e.path === activeThemeId) + if (!entry || !isEntryRemoved(entry)) return + clearTheme() + // eslint-disable-next-line react-hooks/set-state-in-effect -- sync fallback when active theme is deleted + setActiveThemeId(null) + if (vaultPath) tauriCall('set_active_theme', { vaultPath, themeId: null }).catch(() => {}) + }, [entries, activeThemeId, clearTheme, vaultPath, setActiveThemeId]) const switchTheme = useCallback(async (themeId: string) => { if (!vaultPath) return try { await tauriCall('set_active_theme', { vaultPath, themeId }) setActiveThemeId(themeId) - } catch (err) { - console.error('Failed to switch theme:', err) - } - }, [vaultPath]) + } catch (err) { console.error('Failed to switch theme:', err) } + }, [vaultPath, setActiveThemeId]) - const createTheme = useCallback(async (sourceId?: string) => { + const createTheme = useCallback(async (name?: string) => { if (!vaultPath) return '' try { - const newId = await tauriCall('create_theme', { - vaultPath, - sourceId: sourceId ?? null, - }) - await loadThemes() - await switchTheme(newId) - return newId - } catch (err) { - console.error('Failed to create theme:', err) - return '' - } - }, [vaultPath, loadThemes, switchTheme]) + const path = await tauriCall('create_vault_theme', { vaultPath, name: name ?? null }) + await tauriCall('set_active_theme', { vaultPath, themeId: path }) + setActiveThemeId(path) + return path + } catch (err) { console.error('Failed to create theme:', err); return '' } + }, [vaultPath, setActiveThemeId]) +<<<<<<< HEAD return { themes, activeThemeId, activeTheme, isDark, switchTheme, createTheme, reloadThemes: loadThemes } +======= + const reloadThemes = useCallback(async () => { await reload() }, [reload]) + + return { themes, activeThemeId, activeTheme, switchTheme, createTheme, reloadThemes } +>>>>>>> 240be0d (wip: themes-editable — theme management system WIP (13 files, 1014 insertions)) } diff --git a/src/mock-tauri/mock-content.ts b/src/mock-tauri/mock-content.ts index 453547ad..31a49eef 100644 --- a/src/mock-tauri/mock-content.ts +++ b/src/mock-tauri/mock-content.ts @@ -732,5 +732,68 @@ rating: 5 # Designing Data-Intensive Applications Essential reading for anyone building distributed systems. Covers replication, partitioning, transactions, and stream processing. +`, + '/Users/luca/Laputa/theme/default.md': `--- +Is A: Theme +title: Default +primary: "#155DFF" +background: "#FFFFFF" +foreground: "#37352F" +sidebar: "#F7F6F3" +border: "#E9E9E7" +muted: "#F0F0EF" +muted-foreground: "#9B9A97" +accent: "#F0F7FF" +accent-foreground: "#0A3B8F" +font-family: "'Inter', -apple-system, BlinkMacSystemFont, sans-serif" +font-size-base: 14 +line-height-base: 1.6 +--- + +# Default + +Light theme with warm, paper-like tones. +`, + '/Users/luca/Laputa/theme/dark.md': `--- +Is A: Theme +title: Dark +primary: "#155DFF" +background: "#0f0f1a" +foreground: "#e0e0e0" +sidebar: "#1a1a2e" +border: "#2a2a4a" +muted: "#1e1e3a" +muted-foreground: "#6b6b8a" +accent: "#1a2a4a" +accent-foreground: "#8ab4ff" +font-family: "'Inter', -apple-system, BlinkMacSystemFont, sans-serif" +font-size-base: 14 +line-height-base: 1.6 +--- + +# Dark + +Dark variant with deep navy tones. +`, + '/Users/luca/Laputa/theme/minimal.md': `--- +Is A: Theme +title: Minimal +primary: "#000000" +background: "#FAFAFA" +foreground: "#111111" +sidebar: "#F5F5F5" +border: "#E0E0E0" +muted: "#F5F5F5" +muted-foreground: "#888888" +accent: "#F0F0F0" +accent-foreground: "#000000" +font-family: "'SF Mono', 'Menlo', monospace" +font-size-base: 13 +line-height-base: 1.5 +--- + +# Minimal + +High contrast, minimal chrome. `, } diff --git a/src/mock-tauri/mock-entries.ts b/src/mock-tauri/mock-entries.ts index b63cf5e1..9372e35a 100644 --- a/src/mock-tauri/mock-entries.ts +++ b/src/mock-tauri/mock-entries.ts @@ -1121,5 +1121,90 @@ function generateBulkEntries(count: number): VaultEntry[] { return entries } +// Theme entries — seeded vault themes +MOCK_ENTRIES.push( + { + path: '/Users/luca/Laputa/theme/default.md', + filename: 'default.md', + title: 'Default', + isA: 'Theme', + aliases: [], + belongsTo: [], + relatedTo: [], + status: null, + owner: null, + cadence: null, + archived: false, + trashed: false, + trashedAt: null, + modifiedAt: now - 86400 * 30, + createdAt: now - 86400 * 30, + fileSize: 512, + snippet: 'Light theme with warm, paper-like tones.', + wordCount: 10, + relationships: {}, + icon: null, + color: null, + order: null, + sidebarLabel: null, + template: null, + outgoingLinks: [], + }, + { + path: '/Users/luca/Laputa/theme/dark.md', + filename: 'dark.md', + title: 'Dark', + isA: 'Theme', + aliases: [], + belongsTo: [], + relatedTo: [], + status: null, + owner: null, + cadence: null, + archived: false, + trashed: false, + trashedAt: null, + modifiedAt: now - 86400 * 30, + createdAt: now - 86400 * 30, + fileSize: 512, + snippet: 'Dark variant with deep navy tones.', + wordCount: 10, + relationships: {}, + icon: null, + color: null, + order: null, + sidebarLabel: null, + template: null, + outgoingLinks: [], + }, + { + path: '/Users/luca/Laputa/theme/minimal.md', + filename: 'minimal.md', + title: 'Minimal', + isA: 'Theme', + aliases: [], + belongsTo: [], + relatedTo: [], + status: null, + owner: null, + cadence: null, + archived: false, + trashed: false, + trashedAt: null, + modifiedAt: now - 86400 * 30, + createdAt: now - 86400 * 30, + fileSize: 512, + snippet: 'High contrast, minimal chrome.', + wordCount: 10, + relationships: {}, + icon: null, + color: null, + order: null, + sidebarLabel: null, + template: null, + outgoingLinks: [], + }, +) + // Append 9000 generated entries for realistic large-vault testing MOCK_ENTRIES.push(...generateBulkEntries(9000)) diff --git a/src/mock-tauri/mock-handlers.ts b/src/mock-tauri/mock-handlers.ts index 14ebb42d..38514e59 100644 --- a/src/mock-tauri/mock-handlers.ts +++ b/src/mock-tauri/mock-handlers.ts @@ -268,6 +268,32 @@ export const mockHandlers: Record any> = { mockThemes.push({ ...source, id: newId, name: 'Untitled Theme' }) return newId }, + create_vault_theme: (args: { vaultPath: string; name?: string | null }): string => { + const displayName = args.name ?? 'Untitled Theme' + const slug = displayName.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/(^-|-$)/g, '') || 'untitled-theme' + const path = `${args.vaultPath}/theme/${slug}.md` + MOCK_CONTENT[path] = `--- +Is A: Theme +title: ${displayName} +primary: "#155DFF" +background: "#FFFFFF" +foreground: "#37352F" +sidebar: "#F7F6F3" +border: "#E9E9E7" +muted: "#F0F0EF" +muted-foreground: "#9B9A97" +accent: "#F0F7FF" +accent-foreground: "#0A3B8F" +font-family: "'Inter', -apple-system, BlinkMacSystemFont, sans-serif" +font-size-base: 14 +line-height-base: 1.6 +--- + +# ${displayName} +` + syncWindowContent() + return path + }, } export function addMockEntry(_entry: VaultEntry, content: string): void { diff --git a/src/types.ts b/src/types.ts index 542b7e01..9d1c4c39 100644 --- a/src/types.ts +++ b/src/types.ts @@ -120,9 +120,12 @@ export interface SearchResponse { export type SearchMode = 'keyword' | 'semantic' | 'hybrid' export interface ThemeFile { + /** For vault-based themes: absolute note path. For legacy JSON themes: filename stem. */ id: string name: string description: string + /** Absolute path to the vault note (vault-based themes only). */ + path?: string colors: Record typography: Record spacing: Record