From cae821ef4c07859350cb18cedfd52526eb557782 Mon Sep 17 00:00:00 2001 From: Luca Rossi Date: Sat, 28 Feb 2026 23:05:14 +0100 Subject: [PATCH] feat: vault-native theming system with live reload (#154) * 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 * 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 * 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 * fix: use .to_string() instead of format! for string literal (clippy) * style: cargo fmt on theme.rs --------- Co-authored-by: Test Co-authored-by: Claude Opus 4.6 --- design/theming-system.pen | 1 + src-tauri/src/lib.rs | 46 ++- src-tauri/src/theme.rs | 441 +++++++++++++++++++++++++ src-tauri/src/vault/getting_started.rs | 25 ++ src/App.test.tsx | 29 +- src/App.tsx | 6 +- src/components/SettingsPanel.test.tsx | 62 ++-- src/components/SettingsPanel.tsx | 85 ++++- src/hooks/useAppCommands.ts | 10 +- src/hooks/useCommandRegistry.test.ts | 75 ++++- src/hooks/useCommandRegistry.ts | 39 ++- src/hooks/useThemeManager.test.ts | 245 ++++++++++++++ src/hooks/useThemeManager.ts | 118 +++++++ src/mock-tauri/mock-handlers.ts | 41 ++- src/types.ts | 13 + 15 files changed, 1183 insertions(+), 53 deletions(-) create mode 100644 design/theming-system.pen create mode 100644 src-tauri/src/theme.rs create mode 100644 src/hooks/useThemeManager.test.ts create mode 100644 src/hooks/useThemeManager.ts diff --git a/design/theming-system.pen b/design/theming-system.pen new file mode 100644 index 00000000..e75de452 --- /dev/null +++ b/design/theming-system.pen @@ -0,0 +1 @@ +{"children":[{"children":[{"alignItems":"center","children":[{"content":"Settings","fill":"#37352F","fontSize":16,"fontWeight":"600","id":"o6HJK","type":"text"},{"fill":"#787774","height":16,"iconFontFamily":"lucide","iconFontName":"x","id":"zgXAf","type":"icon_font","width":16}],"height":56,"id":"mxSw1","justifyContent":"space_between","name":"Header","padding":[0,24],"stroke":{"align":"inside","fill":"#E9E9E7","thickness":{"bottom":1}},"type":"frame","width":"fill_container"},{"children":[{"content":"Appearance","fill":"#37352F","fontSize":13,"fontWeight":"600","id":"NhMui","type":"text"},{"content":"Choose a theme for your vault. Themes are JSON files in _themes/ — edit them to customize.","fill":"#787774","fontSize":12,"id":"atYQn","lineHeight":1.5,"textGrowth":"fixed-width","type":"text","width":"fill_container"},{"children":[{"alignItems":"center","children":[{"children":[{"fill":"#FFFFFF","height":32,"type":"rectangle","width":20},{"fill":"#F7F6F3","height":32,"type":"rectangle","width":20}],"cornerRadius":4,"height":32,"type":"frame","width":40},{"children":[{"content":"Default","fill":"#37352F","fontSize":13,"fontWeight":"500","type":"text"},{"content":"Light theme with warm tones","fill":"#787774","fontSize":11,"type":"text"}],"gap":2,"layout":"vertical","type":"frame","width":"fill_container"},{"content":"Active","fill":"#155DFF","fontSize":11,"fontWeight":"500","type":"text"}],"cornerRadius":6,"gap":12,"height":56,"name":"Theme Item — Default (Active)","padding":[0,12],"stroke":{"align":"inside","fill":"#155DFF","thickness":2},"type":"frame","width":"fill_container"},{"alignItems":"center","children":[{"children":[{"fill":"#1a1a2e","height":32,"type":"rectangle","width":20},{"fill":"#0f0f1a","height":32,"type":"rectangle","width":20}],"cornerRadius":4,"height":32,"type":"frame","width":40},{"children":[{"content":"Dark","fill":"#37352F","fontSize":13,"fontWeight":"500","type":"text"},{"content":"Dark variant with deep navy tones","fill":"#787774","fontSize":11,"type":"text"}],"gap":2,"layout":"vertical","type":"frame","width":"fill_container"}],"cornerRadius":6,"gap":12,"height":56,"name":"Theme Item — Dark","padding":[0,12],"stroke":{"align":"inside","fill":"#E9E9E7","thickness":1},"type":"frame","width":"fill_container"},{"alignItems":"center","children":[{"children":[{"fill":"#FAFAFA","height":32,"type":"rectangle","width":20},{"fill":"#111111","height":32,"type":"rectangle","width":20}],"cornerRadius":4,"height":32,"type":"frame","width":40},{"children":[{"content":"Minimal","fill":"#37352F","fontSize":13,"fontWeight":"500","type":"text"},{"content":"High contrast, minimal chrome","fill":"#787774","fontSize":11,"type":"text"}],"gap":2,"layout":"vertical","type":"frame","width":"fill_container"}],"cornerRadius":6,"gap":12,"height":56,"name":"Theme Item — Minimal","padding":[0,12],"stroke":{"align":"inside","fill":"#E9E9E7","thickness":1},"type":"frame","width":"fill_container"}],"gap":8,"layout":"vertical","name":"Theme List","type":"frame","width":"fill_container"},{"children":[{"alignItems":"center","children":[{"fill":"#787774","height":14,"iconFontFamily":"lucide","iconFontName":"plus","type":"icon_font","width":14},{"content":"New Theme","fill":"#37352F","fontSize":12,"fontWeight":"500","type":"text"}],"cornerRadius":6,"gap":6,"height":32,"name":"New Theme Button","padding":[0,12],"stroke":{"align":"inside","fill":"#E9E9E7","thickness":1},"type":"frame"},{"alignItems":"center","children":[{"fill":"#787774","height":14,"iconFontFamily":"lucide","iconFontName":"pencil","type":"icon_font","width":14},{"content":"Open in Editor","fill":"#37352F","fontSize":12,"fontWeight":"500","type":"text"}],"cornerRadius":6,"gap":6,"height":32,"name":"Open in Editor Button","padding":[0,12],"stroke":{"align":"inside","fill":"#E9E9E7","thickness":1},"type":"frame"}],"gap":8,"name":"Actions","type":"frame","width":"fill_container"}],"gap":20,"layout":"vertical","name":"Body","padding":24,"type":"frame","width":"fill_container"},{"alignItems":"center","children":[{"alignItems":"center","children":[{"content":"Cancel","fill":"#37352F","fontSize":13,"type":"text"}],"cornerRadius":6,"height":32,"padding":[0,16],"stroke":{"align":"inside","fill":"#E9E9E7","thickness":1},"type":"frame"},{"alignItems":"center","children":[{"content":"Save","fill":"#FFFFFF","fontSize":13,"type":"text"}],"cornerRadius":6,"fill":"#155DFF","height":32,"padding":[0,16],"type":"frame"}],"gap":8,"height":56,"justifyContent":"end","name":"Footer","padding":[0,24],"stroke":{"align":"inside","fill":"#E9E9E7","thickness":{"top":1}},"type":"frame","width":"fill_container"}],"fill":"#FFFFFF","layout":"vertical","name":"Settings — Appearance","type":"frame","width":520,"x":0,"y":0},{"children":[{"alignItems":"center","children":[{"content":"Switch theme...","fill":"#787774","fontSize":15,"type":"text"}],"height":44,"name":"Search","padding":[0,16],"stroke":{"align":"inside","fill":"#E9E9E7","thickness":{"bottom":1}},"type":"frame","width":"fill_container"},{"children":[{"content":"APPEARANCE","fill":"#787774","fontSize":11,"fontWeight":"600","letterSpacing":0.8,"type":"text"},{"alignItems":"center","children":[{"children":[{"fill":"#FFFFFF","height":16,"type":"rectangle","width":10},{"fill":"#F7F6F3","height":16,"type":"rectangle","width":10}],"cornerRadius":3,"height":16,"type":"frame","width":20},{"content":"Switch to Default","fill":"#37352F","fontSize":13,"type":"text"},{"fill":"#155DFF","height":14,"iconFontFamily":"lucide","iconFontName":"check","type":"icon_font","width":14}],"cornerRadius":6,"fill":"#EBEBEA","gap":8,"height":36,"name":"Row — Active","padding":[0,12],"type":"frame","width":"fill_container"},{"alignItems":"center","children":[{"children":[{"fill":"#1a1a2e","height":16,"type":"rectangle","width":10},{"fill":"#0f0f1a","height":16,"type":"rectangle","width":10}],"cornerRadius":3,"height":16,"type":"frame","width":20},{"content":"Switch to Dark","fill":"#37352F","fontSize":13,"type":"text"}],"cornerRadius":6,"gap":8,"height":36,"name":"Row — Dark","padding":[0,12],"type":"frame","width":"fill_container"},{"alignItems":"center","children":[{"children":[{"fill":"#FAFAFA","height":16,"type":"rectangle","width":10},{"fill":"#111111","height":16,"type":"rectangle","width":10}],"cornerRadius":3,"height":16,"type":"frame","width":20},{"content":"Switch to Minimal","fill":"#37352F","fontSize":13,"type":"text"}],"cornerRadius":6,"gap":8,"height":36,"name":"Row — Minimal","padding":[0,12],"type":"frame","width":"fill_container"},{"alignItems":"center","children":[{"fill":"#787774","height":16,"iconFontFamily":"lucide","iconFontName":"plus","type":"icon_font","width":16},{"content":"New Theme","fill":"#37352F","fontSize":13,"type":"text"}],"cornerRadius":6,"gap":8,"height":36,"name":"Row — New Theme","padding":[0,12],"type":"frame","width":"fill_container"}],"layout":"vertical","name":"Results","padding":[4,0],"type":"frame","width":"fill_container"},{"alignItems":"center","children":[{"content":"↑↓ navigate","fill":"#787774","fontSize":11,"type":"text"},{"content":"↵ select","fill":"#787774","fontSize":11,"type":"text"},{"content":"esc close","fill":"#787774","fontSize":11,"type":"text"}],"gap":16,"height":28,"name":"Footer","padding":[0,16],"stroke":{"align":"inside","fill":"#E9E9E7","thickness":{"top":1}},"type":"frame","width":"fill_container"}],"cornerRadius":12,"fill":"#FFFFFF","layout":"vertical","name":"Command Palette — Theme Switching","stroke":{"align":"inside","fill":"#E9E9E7","thickness":1},"type":"frame","width":520,"x":620,"y":0},{"children":[{"children":[{"alignItems":"center","children":[{"fill":"#787774","height":14,"iconFontFamily":"lucide","iconFontName":"file","type":"icon_font","width":14},{"content":"_themes/default.json","fill":"#37352F","fontSize":12,"fontWeight":"500","type":"text"}],"gap":8,"height":36,"padding":[0,12],"stroke":{"align":"inside","fill":"#E9E9E7","thickness":{"bottom":1}},"type":"frame","width":"fill_container"},{"children":[{"content":"{","fill":"#37352F","fontFamily":"IBM Plex Mono","fontSize":12,"type":"text"},{"content":" \"name\": \"Default\",","fill":"#37352F","fontFamily":"IBM Plex Mono","fontSize":12,"type":"text"},{"content":" \"colors\": {","fill":"#37352F","fontFamily":"IBM Plex Mono","fontSize":12,"type":"text"},{"content":" \"background\": \"#FFFFFF\",","fill":"#155DFF","fontFamily":"IBM Plex Mono","fontSize":12,"fontWeight":"600","type":"text"},{"content":" \"sidebar-background\": \"#F7F6F3\",","fill":"#37352F","fontFamily":"IBM Plex Mono","fontSize":12,"type":"text"},{"content":" \"accent\": \"#155DFF\"","fill":"#37352F","fontFamily":"IBM Plex Mono","fontSize":12,"type":"text"},{"content":" }","fill":"#37352F","fontFamily":"IBM Plex Mono","fontSize":12,"type":"text"},{"content":"}","fill":"#37352F","fontFamily":"IBM Plex Mono","fontSize":12,"type":"text"}],"gap":2,"layout":"vertical","padding":16,"type":"frame","width":"fill_container"}],"cornerRadius":8,"fill":"#FFFFFF","layout":"vertical","name":"Editor — Theme JSON","stroke":{"align":"inside","fill":"#E9E9E7","thickness":1},"type":"frame","width":400},{"alignItems":"center","children":[{"fill":"#787774","height":24,"iconFontFamily":"lucide","iconFontName":"arrow-right","type":"icon_font","width":24},{"content":"~300ms","fill":"#787774","fontSize":10,"fontWeight":"500","type":"text"}],"justifyContent":"center","layout":"vertical","name":"Arrow","type":"frame","width":40},{"children":[{"alignItems":"center","children":[{"fill":"#FF5F57","height":8,"type":"ellipse","width":8},{"fill":"#FEBC2E","height":8,"type":"ellipse","width":8},{"fill":"#28C840","height":8,"type":"ellipse","width":8}],"fill":"#F7F6F3","gap":6,"height":28,"padding":[0,8],"stroke":{"align":"inside","fill":"#E9E9E7","thickness":{"bottom":1}},"type":"frame","width":"fill_container"},{"children":[{"children":[{"cornerRadius":4,"fill":"#EBEBEA","height":20,"type":"frame","width":"fill_container"},{"cornerRadius":4,"height":20,"type":"frame","width":"fill_container"},{"cornerRadius":4,"height":20,"type":"frame","width":"fill_container"}],"fill":"#F7F6F3","gap":4,"height":"fill_container","layout":"vertical","padding":[8,6],"stroke":{"align":"inside","fill":"#E9E9E7","thickness":{"right":1}},"type":"frame","width":80},{"children":[{"cornerRadius":2,"fill":"#37352F","height":12,"type":"frame","width":160},{"cornerRadius":2,"fill":"#F0F0EF","height":8,"type":"frame","width":"fill_container"},{"cornerRadius":2,"fill":"#F0F0EF","height":8,"type":"frame","width":220},{"cornerRadius":2,"fill":"#F0F0EF","height":8,"type":"frame","width":180}],"gap":8,"height":"fill_container","layout":"vertical","padding":16,"type":"frame","width":"fill_container"}],"height":260,"type":"frame","width":"fill_container"},{"alignItems":"center","children":[{"fill":"#38A169","height":6,"type":"ellipse","width":6},{"content":"Live — CSS variables update instantly","fill":"#787774","fontSize":10,"type":"text"}],"gap":6,"height":28,"padding":[0,12],"stroke":{"align":"inside","fill":"#E9E9E7","thickness":{"top":1}},"type":"frame","width":"fill_container"}],"cornerRadius":8,"fill":"#FFFFFF","layout":"vertical","name":"App Preview","stroke":{"align":"inside","fill":"#E9E9E7","thickness":1},"type":"frame","width":380}],"fill":"#F0F0EF","gap":24,"name":"Live Theme Editing Flow","padding":24,"type":"frame","width":900,"x":0,"y":700}],"variables":{}} \ No newline at end of file diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 923ed406..12bb8c4a 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -6,6 +6,7 @@ pub mod mcp; pub mod menu; pub mod search; pub mod settings; +pub mod theme; pub mod vault; use std::borrow::Cow; @@ -19,6 +20,7 @@ use git::{GitCommit, GitPullResult, LastCommitInfo, ModifiedFile}; use github::{DeviceFlowPollResult, DeviceFlowStart, GitHubUser, GithubRepo}; use search::SearchResponse; use settings::Settings; +use theme::{ThemeFile, VaultSettings}; use vault::{RenameResult, VaultEntry}; /// Expand a leading `~` or `~/` in a path string to the user's home directory. @@ -289,6 +291,42 @@ async fn register_mcp_tools(vault_path: String) -> Result { .map_err(|e| format!("Registration task failed: {e}"))? } +#[tauri::command] +fn list_themes(vault_path: String) -> Result, String> { + let vault_path = expand_tilde(&vault_path); + theme::list_themes(&vault_path) +} + +#[tauri::command] +fn get_theme(vault_path: String, theme_id: String) -> Result { + let vault_path = expand_tilde(&vault_path); + theme::get_theme(&vault_path, &theme_id) +} + +#[tauri::command] +fn get_vault_settings(vault_path: String) -> Result { + let vault_path = expand_tilde(&vault_path); + theme::get_vault_settings(&vault_path) +} + +#[tauri::command] +fn save_vault_settings(vault_path: String, settings: VaultSettings) -> Result<(), String> { + let vault_path = expand_tilde(&vault_path); + theme::save_vault_settings(&vault_path, settings) +} + +#[tauri::command] +fn set_active_theme(vault_path: String, theme_id: String) -> Result<(), String> { + let vault_path = expand_tilde(&vault_path); + theme::set_active_theme(&vault_path, &theme_id) +} + +#[tauri::command] +fn create_theme(vault_path: String, source_id: Option) -> Result { + let vault_path = expand_tilde(&vault_path); + theme::create_theme(&vault_path, source_id.as_deref()) +} + fn log_startup_result(label: &str, result: Result) { match result { Ok(n) if n > 0 => log::info!("{}: {} files", label, n), @@ -443,7 +481,13 @@ pub fn run() { create_getting_started_vault, check_vault_exists, get_default_vault_path, - register_mcp_tools + register_mcp_tools, + list_themes, + get_theme, + get_vault_settings, + save_vault_settings, + set_active_theme, + create_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 new file mode 100644 index 00000000..01544366 --- /dev/null +++ b/src-tauri/src/theme.rs @@ -0,0 +1,441 @@ +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; +use std::fs; +use std::path::Path; + +/// A theme file parsed from _themes/*.json in the vault. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ThemeFile { + /// Filename stem (e.g. "default" for _themes/default.json) + #[serde(default)] + pub id: String, + pub name: String, + #[serde(default)] + pub description: String, + #[serde(default)] + pub colors: HashMap, + #[serde(default)] + pub typography: HashMap, + #[serde(default)] + pub spacing: HashMap, +} + +/// Vault-level settings stored in .laputa/settings.json (git-tracked). +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct VaultSettings { + #[serde(default)] + pub theme: Option, +} + +/// List all theme files in _themes/ directory of the vault. +pub fn list_themes(vault_path: &str) -> Result, String> { + let themes_dir = Path::new(vault_path).join("_themes"); + if !themes_dir.is_dir() { + return Ok(Vec::new()); + } + + let mut themes = Vec::new(); + let entries = + fs::read_dir(&themes_dir).map_err(|e| format!("Failed to read _themes directory: {e}"))?; + + for entry in entries.flatten() { + let path = entry.path(); + if path.extension().and_then(|e| e.to_str()) != Some("json") { + continue; + } + match parse_theme_file(&path) { + Ok(theme) => themes.push(theme), + Err(e) => log::warn!("Skipping theme file {}: {e}", path.display()), + } + } + + themes.sort_by(|a, b| a.name.cmp(&b.name)); + Ok(themes) +} + +/// Parse a single theme JSON file. +fn parse_theme_file(path: &Path) -> Result { + let id = path + .file_stem() + .and_then(|s| s.to_str()) + .map(|s| s.to_string()) + .ok_or_else(|| "Invalid theme filename".to_string())?; + + let content = + fs::read_to_string(path).map_err(|e| format!("Failed to read {}: {e}", path.display()))?; + + let mut theme: ThemeFile = serde_json::from_str(&content) + .map_err(|e| format!("Failed to parse {}: {e}", path.display()))?; + + theme.id = id; + Ok(theme) +} + +/// Read vault-level settings from .laputa/settings.json. +pub fn get_vault_settings(vault_path: &str) -> Result { + let settings_path = Path::new(vault_path).join(".laputa").join("settings.json"); + if !settings_path.exists() { + return Ok(VaultSettings::default()); + } + let content = fs::read_to_string(&settings_path) + .map_err(|e| format!("Failed to read vault settings: {e}"))?; + serde_json::from_str(&content).map_err(|e| format!("Failed to parse vault settings: {e}")) +} + +/// Save vault-level settings to .laputa/settings.json. +pub fn save_vault_settings(vault_path: &str, settings: VaultSettings) -> Result<(), String> { + let laputa_dir = Path::new(vault_path).join(".laputa"); + fs::create_dir_all(&laputa_dir) + .map_err(|e| format!("Failed to create .laputa directory: {e}"))?; + + let json = serde_json::to_string_pretty(&settings) + .map_err(|e| format!("Failed to serialize vault settings: {e}"))?; + fs::write(laputa_dir.join("settings.json"), json) + .map_err(|e| format!("Failed to write vault settings: {e}")) +} + +/// Set the active theme in vault settings. +pub fn set_active_theme(vault_path: &str, theme_id: &str) -> Result<(), String> { + let mut settings = get_vault_settings(vault_path)?; + settings.theme = Some(theme_id.to_string()); + save_vault_settings(vault_path, settings) +} + +/// Read a single theme file by ID from the vault's _themes/ directory. +pub fn get_theme(vault_path: &str, theme_id: &str) -> Result { + let path = Path::new(vault_path) + .join("_themes") + .join(format!("{theme_id}.json")); + if !path.exists() { + return Err(format!("Theme not found: {theme_id}")); + } + parse_theme_file(&path) +} + +/// Create a new theme file by copying the active theme (or default). +/// Returns the ID of the new theme. +pub fn create_theme(vault_path: &str, source_id: Option<&str>) -> Result { + let themes_dir = Path::new(vault_path).join("_themes"); + fs::create_dir_all(&themes_dir) + .map_err(|e| format!("Failed to create _themes directory: {e}"))?; + + let new_id = find_available_id(&themes_dir, "untitled"); + + let source = source_id.unwrap_or("default"); + let source_path = themes_dir.join(format!("{source}.json")); + + let content = if source_path.exists() { + let mut theme: serde_json::Value = serde_json::from_str( + &fs::read_to_string(&source_path) + .map_err(|e| format!("Failed to read source theme: {e}"))?, + ) + .map_err(|e| format!("Failed to parse source theme: {e}"))?; + + if let Some(obj) = theme.as_object_mut() { + obj.insert( + "name".to_string(), + serde_json::Value::String("Untitled Theme".to_string()), + ); + } + serde_json::to_string_pretty(&theme) + .map_err(|e| format!("Failed to serialize new theme: {e}"))? + } else { + default_theme_json("Untitled Theme") + }; + + fs::write(themes_dir.join(format!("{new_id}.json")), content) + .map_err(|e| format!("Failed to write new theme: {e}"))?; + + Ok(new_id) +} + +/// Find a filename that doesn't conflict (untitled, untitled-2, untitled-3, ...). +fn find_available_id(dir: &Path, base: &str) -> 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!({ + "name": name, + "description": "Custom theme", + "colors": { + "background": "#FFFFFF", + "foreground": "#37352F", + "sidebar-background": "#F7F6F3", + "accent": "#155DFF", + "muted": "#787774", + "border": "#E9E9E7" + }, + "typography": { + "font-family": "system-ui", + "font-size-base": "14px" + }, + "spacing": { + "sidebar-width": "240px" + } + })) + .unwrap() +} + +/// Content for the built-in default (light) theme. +pub const DEFAULT_THEME: &str = r##"{ + "name": "Default", + "description": "Light theme with warm, paper-like tones", + "colors": { + "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-background": "#F7F6F3", + "sidebar-foreground": "#37352F", + "sidebar-border": "#E9E9E7", + "sidebar-accent": "#EBEBEA" + }, + "typography": { + "font-family": "'Inter', -apple-system, BlinkMacSystemFont, sans-serif", + "font-size-base": "14px" + }, + "spacing": { + "sidebar-width": "250px" + } +}"##; + +/// Content for the built-in dark theme. +pub const DARK_THEME: &str = r##"{ + "name": "Dark", + "description": "Dark variant with deep navy tones", + "colors": { + "background": "#0f0f1a", + "foreground": "#e0e0e0", + "card": "#16162a", + "popover": "#1e1e3a", + "primary": "#155DFF", + "primary-foreground": "#FFFFFF", + "secondary": "#2a2a4a", + "secondary-foreground": "#e0e0e0", + "muted": "#1e1e3a", + "muted-foreground": "#888888", + "accent": "#2a2a4a", + "accent-foreground": "#e0e0e0", + "destructive": "#f44336", + "border": "#2a2a4a", + "input": "#2a2a4a", + "ring": "#155DFF", + "sidebar-background": "#1a1a2e", + "sidebar-foreground": "#e0e0e0", + "sidebar-border": "#2a2a4a", + "sidebar-accent": "#2a2a4a" + }, + "typography": { + "font-family": "'Inter', -apple-system, BlinkMacSystemFont, sans-serif", + "font-size-base": "14px" + }, + "spacing": { + "sidebar-width": "250px" + } +}"##; + +/// Content for the built-in minimal theme. +pub const MINIMAL_THEME: &str = r##"{ + "name": "Minimal", + "description": "High contrast, minimal chrome", + "colors": { + "background": "#FAFAFA", + "foreground": "#111111", + "card": "#FFFFFF", + "popover": "#FFFFFF", + "primary": "#000000", + "primary-foreground": "#FFFFFF", + "secondary": "#F0F0F0", + "secondary-foreground": "#111111", + "muted": "#F5F5F5", + "muted-foreground": "#666666", + "accent": "#F0F0F0", + "accent-foreground": "#111111", + "destructive": "#CC0000", + "border": "#E0E0E0", + "input": "#E0E0E0", + "ring": "#000000", + "sidebar-background": "#F5F5F5", + "sidebar-foreground": "#111111", + "sidebar-border": "#E0E0E0", + "sidebar-accent": "#E8E8E8" + }, + "typography": { + "font-family": "'SF Mono', 'Menlo', monospace", + "font-size-base": "13px" + }, + "spacing": { + "sidebar-width": "220px" + } +}"##; + +#[cfg(test)] +mod tests { + use super::*; + use std::fs; + use tempfile::TempDir; + + fn setup_vault_with_themes(dir: &TempDir) -> String { + let vault = dir.path().join("vault"); + let themes_dir = vault.join("_themes"); + fs::create_dir_all(&themes_dir).unwrap(); + fs::write(themes_dir.join("default.json"), DEFAULT_THEME).unwrap(); + fs::write(themes_dir.join("dark.json"), DARK_THEME).unwrap(); + vault.to_string_lossy().to_string() + } + + #[test] + fn test_list_themes_returns_sorted_list() { + let dir = TempDir::new().unwrap(); + let vault = setup_vault_with_themes(&dir); + let themes = list_themes(&vault).unwrap(); + assert_eq!(themes.len(), 2); + assert_eq!(themes[0].id, "dark"); + assert_eq!(themes[1].id, "default"); + } + + #[test] + fn test_list_themes_empty_when_no_dir() { + let dir = TempDir::new().unwrap(); + let vault = dir.path().join("empty-vault"); + fs::create_dir_all(&vault).unwrap(); + let themes = list_themes(vault.to_str().unwrap()).unwrap(); + assert!(themes.is_empty()); + } + + #[test] + fn test_get_theme_by_id() { + let dir = TempDir::new().unwrap(); + let vault = setup_vault_with_themes(&dir); + let theme = get_theme(&vault, "default").unwrap(); + assert_eq!(theme.name, "Default"); + assert!(!theme.colors.is_empty()); + } + + #[test] + fn test_get_theme_not_found() { + let dir = TempDir::new().unwrap(); + let vault = setup_vault_with_themes(&dir); + let result = get_theme(&vault, "nonexistent"); + assert!(result.is_err()); + } + + #[test] + fn test_vault_settings_roundtrip() { + let dir = TempDir::new().unwrap(); + let vault = dir.path().join("vault"); + fs::create_dir_all(&vault).unwrap(); + let vp = vault.to_str().unwrap(); + + // Default settings have no theme + let settings = get_vault_settings(vp).unwrap(); + assert!(settings.theme.is_none()); + + // Set and read back + set_active_theme(vp, "dark").unwrap(); + let settings = get_vault_settings(vp).unwrap(); + assert_eq!(settings.theme.as_deref(), Some("dark")); + } + + #[test] + fn test_vault_settings_creates_laputa_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(".laputa").exists()); + save_vault_settings( + vp, + VaultSettings { + theme: Some("light".into()), + }, + ) + .unwrap(); + assert!(vault.join(".laputa").join("settings.json").exists()); + } + + #[test] + fn test_create_theme_copies_source() { + let dir = TempDir::new().unwrap(); + let vault = setup_vault_with_themes(&dir); + let new_id = create_theme(&vault, Some("default")).unwrap(); + assert_eq!(new_id, "untitled"); + + let theme = get_theme(&vault, &new_id).unwrap(); + assert_eq!(theme.name, "Untitled Theme"); + assert!(!theme.colors.is_empty()); + } + + #[test] + fn test_create_theme_increments_id() { + let dir = TempDir::new().unwrap(); + let vault = setup_vault_with_themes(&dir); + + let id1 = create_theme(&vault, None).unwrap(); + assert_eq!(id1, "untitled"); + + let id2 = create_theme(&vault, None).unwrap(); + assert_eq!(id2, "untitled-2"); + } + + #[test] + fn test_parse_all_builtin_themes() { + for (name, content) in [ + ("default", DEFAULT_THEME), + ("dark", DARK_THEME), + ("minimal", MINIMAL_THEME), + ] { + let theme: ThemeFile = serde_json::from_str(content) + .unwrap_or_else(|e| panic!("Failed to parse {name} theme: {e}")); + assert!(!theme.name.is_empty(), "{name} theme should have a name"); + assert!(!theme.colors.is_empty(), "{name} theme should have colors"); + } + } + + #[test] + fn test_list_themes_ignores_non_json_files() { + let dir = TempDir::new().unwrap(); + let vault = setup_vault_with_themes(&dir); + let themes_dir = Path::new(&vault).join("_themes"); + fs::write(themes_dir.join("readme.txt"), "not a theme").unwrap(); + fs::write(themes_dir.join(".DS_Store"), "").unwrap(); + + let themes = list_themes(&vault).unwrap(); + assert_eq!(themes.len(), 2); // only default and dark + } + + #[test] + fn test_list_themes_skips_malformed_json() { + let dir = TempDir::new().unwrap(); + let vault = setup_vault_with_themes(&dir); + let themes_dir = Path::new(&vault).join("_themes"); + fs::write(themes_dir.join("broken.json"), "not valid json{{{").unwrap(); + + let themes = list_themes(&vault).unwrap(); + assert_eq!(themes.len(), 2); // broken.json is skipped + } +} diff --git a/src-tauri/src/vault/getting_started.rs b/src-tauri/src/vault/getting_started.rs index eaba6e70..23cd8a12 100644 --- a/src-tauri/src/vault/getting_started.rs +++ b/src-tauri/src/vault/getting_started.rs @@ -394,6 +394,17 @@ pub fn create_getting_started_vault(target_path: &str) -> Result .map_err(|e| format!("Failed to write {}: {}", sample.rel_path, e))?; } + // Seed built-in themes + let themes_dir = vault_dir.join("_themes"); + fs::create_dir_all(&themes_dir) + .map_err(|e| format!("Failed to create _themes directory: {e}"))?; + fs::write(themes_dir.join("default.json"), crate::theme::DEFAULT_THEME) + .map_err(|e| format!("Failed to write default theme: {e}"))?; + fs::write(themes_dir.join("dark.json"), crate::theme::DARK_THEME) + .map_err(|e| format!("Failed to write dark theme: {e}"))?; + fs::write(themes_dir.join("minimal.json"), crate::theme::MINIMAL_THEME) + .map_err(|e| format!("Failed to write minimal theme: {e}"))?; + crate::git::init_repo(target_path)?; Ok(vault_dir @@ -566,6 +577,20 @@ mod tests { assert!(log_str.contains("Initial vault setup")); } + #[test] + fn test_create_getting_started_vault_seeds_themes() { + let dir = tempfile::TempDir::new().unwrap(); + let vault_path = dir.path().join("theme-vault"); + create_getting_started_vault(vault_path.to_str().unwrap()).unwrap(); + + assert!(vault_path.join("_themes/default.json").exists()); + assert!(vault_path.join("_themes/dark.json").exists()); + assert!(vault_path.join("_themes/minimal.json").exists()); + + let themes = crate::theme::list_themes(vault_path.to_str().unwrap()).unwrap(); + assert_eq!(themes.len(), 3); + } + #[test] fn test_create_getting_started_vault_no_untracked_files() { let dir = tempfile::TempDir::new().unwrap(); diff --git a/src/App.test.tsx b/src/App.test.tsx index cf3a0d21..74debd08 100644 --- a/src/App.test.tsx +++ b/src/App.test.tsx @@ -59,21 +59,24 @@ const mockAllContent: Record = { '/vault/topic/dev.md': '---\ntitle: Software Development\nis_a: Topic\n---\n\n# Software Development\n', } +const mockCommandResults: Record = { + list_vault: mockEntries, + get_all_content: mockAllContent, + get_modified_files: [], + get_note_content: mockAllContent['/vault/project/test.md'] || '', + get_file_history: [], + get_settings: { anthropic_key: null, openai_key: null, google_key: null, github_token: null, github_username: null, auto_pull_interval_minutes: null }, + git_pull: { status: 'up_to_date', message: 'Already up to date', updatedFiles: [], conflictFiles: [] }, + save_settings: null, + check_vault_exists: true, + get_default_vault_path: '/Users/mock/Documents/Laputa', + list_themes: [], + get_vault_settings: { theme: null }, +} + vi.mock('./mock-tauri', () => ({ isTauri: () => false, - mockInvoke: vi.fn(async (cmd: string) => { - if (cmd === 'list_vault') return mockEntries - if (cmd === 'get_all_content') return mockAllContent - if (cmd === 'get_modified_files') return [] - if (cmd === 'get_note_content') return mockAllContent['/vault/project/test.md'] || '' - if (cmd === 'get_file_history') return [] - if (cmd === 'get_settings') return { anthropic_key: null, openai_key: null, google_key: null, github_token: null, github_username: null, auto_pull_interval_minutes: null } - if (cmd === 'git_pull') return { status: 'up_to_date', message: 'Already up to date', updatedFiles: [], conflictFiles: [] } - if (cmd === 'save_settings') return null - if (cmd === 'check_vault_exists') return true - if (cmd === 'get_default_vault_path') return '/Users/mock/Documents/Laputa' - return null - }), + mockInvoke: vi.fn(async (cmd: string) => mockCommandResults[cmd] ?? null), addMockEntry: vi.fn(), updateMockContent: vi.fn(), })) diff --git a/src/App.tsx b/src/App.tsx index 2c804e4d..7bfb6fdc 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -30,6 +30,7 @@ import { useNavigationHistory } from './hooks/useNavigationHistory' import { useAutoSync } from './hooks/useAutoSync' import { useZoom } from './hooks/useZoom' import { useOnboarding } from './hooks/useOnboarding' +import { useThemeManager } from './hooks/useThemeManager' import { UpdateBanner } from './components/UpdateBanner' import { setApiKey } from './utils/ai-chat' import { extractOutgoingLinks } from './utils/wikilinks' @@ -122,6 +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) useEffect(() => { setApiKey(settings.anthropic_key ?? '') }, [settings.anthropic_key]) useMcpRegistration(resolvedPath, setToastMessage) @@ -283,6 +285,8 @@ function App() { onSelectNote: notes.handleSelectNote, onGoBack: handleGoBack, onGoForward: handleGoForward, canGoBack: navHistory.canGoBack, canGoForward: navHistory.canGoForward, + themes: themeManager.themes, activeThemeId: themeManager.activeThemeId, + onSwitchTheme: themeManager.switchTheme, onCreateTheme: () => themeManager.createTheme(), }) const { status: updateStatus, actions: updateActions } = useUpdater() @@ -387,7 +391,7 @@ function App() { - + { const onSave = vi.fn() const onClose = vi.fn() @@ -45,14 +55,14 @@ describe('SettingsPanel', () => { it('renders nothing when not open', () => { const { container } = render( - + ) expect(container.innerHTML).toBe('') }) it('renders modal when open', () => { render( - + ) expect(screen.getByText('Settings')).toBeInTheDocument() expect(screen.getByText('AI Provider Keys')).toBeInTheDocument() @@ -61,7 +71,7 @@ describe('SettingsPanel', () => { it('shows three key fields with labels', () => { render( - + ) expect(screen.getByText('Anthropic')).toBeInTheDocument() expect(screen.getByText('OpenAI')).toBeInTheDocument() @@ -70,7 +80,7 @@ describe('SettingsPanel', () => { it('populates fields from settings', () => { render( - + ) const anthropicInput = screen.getByTestId('settings-key-anthropic') as HTMLInputElement const openaiInput = screen.getByTestId('settings-key-openai') as HTMLInputElement @@ -83,7 +93,7 @@ describe('SettingsPanel', () => { it('calls onSave with trimmed keys on save', () => { render( - + ) const anthropicInput = screen.getByTestId('settings-key-anthropic') fireEvent.change(anthropicInput, { target: { value: ' sk-ant-test ' } }) @@ -103,7 +113,7 @@ describe('SettingsPanel', () => { it('converts empty/whitespace keys to null', () => { render( - + ) // Clear the anthropic key field const anthropicInput = screen.getByTestId('settings-key-anthropic') @@ -123,7 +133,7 @@ describe('SettingsPanel', () => { it('calls onClose when Cancel is clicked', () => { render( - + ) fireEvent.click(screen.getByText('Cancel')) expect(onClose).toHaveBeenCalled() @@ -131,7 +141,7 @@ describe('SettingsPanel', () => { it('calls onClose when close button is clicked', () => { render( - + ) fireEvent.click(screen.getByTitle('Close settings')) expect(onClose).toHaveBeenCalled() @@ -139,7 +149,7 @@ describe('SettingsPanel', () => { it('calls onClose on Escape key', () => { render( - + ) fireEvent.keyDown(screen.getByTestId('settings-panel'), { key: 'Escape' }) expect(onClose).toHaveBeenCalled() @@ -147,7 +157,7 @@ describe('SettingsPanel', () => { it('saves on Cmd+Enter', () => { render( - + ) const anthropicInput = screen.getByTestId('settings-key-anthropic') fireEvent.change(anthropicInput, { target: { value: 'sk-ant-test' } }) @@ -165,7 +175,7 @@ describe('SettingsPanel', () => { it('calls onClose when clicking backdrop', () => { render( - + ) fireEvent.click(screen.getByTestId('settings-panel')) expect(onClose).toHaveBeenCalled() @@ -173,7 +183,7 @@ describe('SettingsPanel', () => { it('clears a key field when X button is clicked', () => { render( - + ) const clearBtn = screen.getByTestId('clear-anthropic') fireEvent.click(clearBtn) @@ -184,14 +194,14 @@ describe('SettingsPanel', () => { it('shows keyboard shortcut hint in footer', () => { render( - + ) expect(screen.getByText(/to open settings/)).toBeInTheDocument() }) it('resets fields when reopened with different settings', () => { const { rerender } = render( - + ) // Verify initial state const anthropicInput = screen.getByTestId('settings-key-anthropic') as HTMLInputElement @@ -199,11 +209,11 @@ describe('SettingsPanel', () => { // Close and reopen with different settings rerender( - + ) const newSettings: Settings = { ...emptySettings, anthropic_key: 'new-key' } rerender( - + ) const updatedInput = screen.getByTestId('settings-key-anthropic') as HTMLInputElement expect(updatedInput.value).toBe('new-key') @@ -212,7 +222,7 @@ describe('SettingsPanel', () => { describe('GitHub OAuth section', () => { it('shows Login with GitHub button when not connected', () => { render( - + ) expect(screen.getByTestId('github-login')).toBeInTheDocument() expect(screen.getByText('Login with GitHub')).toBeInTheDocument() @@ -220,7 +230,7 @@ describe('SettingsPanel', () => { it('does not show GitHub token input field', () => { render( - + ) expect(screen.queryByTestId('settings-key-github-token')).not.toBeInTheDocument() expect(screen.queryByPlaceholderText('ghp_... or gho_...')).not.toBeInTheDocument() @@ -233,7 +243,7 @@ describe('SettingsPanel', () => { github_username: 'lucaong', } render( - + ) expect(screen.getByTestId('github-connected')).toBeInTheDocument() expect(screen.getByText('lucaong')).toBeInTheDocument() @@ -248,7 +258,7 @@ describe('SettingsPanel', () => { github_username: 'lucaong', } render( - + ) fireEvent.click(screen.getByTestId('github-disconnect')) @@ -277,7 +287,7 @@ describe('SettingsPanel', () => { }) render( - + ) fireEvent.click(screen.getByTestId('github-login')) @@ -308,7 +318,7 @@ describe('SettingsPanel', () => { }) render( - + ) fireEvent.click(screen.getByTestId('github-login')) @@ -329,7 +339,7 @@ describe('SettingsPanel', () => { }) render( - + ) fireEvent.click(screen.getByTestId('github-login')) @@ -342,7 +352,7 @@ describe('SettingsPanel', () => { it('shows GitHub section description about connecting', () => { render( - + ) expect(screen.getByText(/Connect your GitHub account/)).toBeInTheDocument() }) @@ -357,7 +367,7 @@ describe('SettingsPanel', () => { }) render( - + ) fireEvent.click(screen.getByTestId('github-login')) @@ -379,7 +389,7 @@ describe('SettingsPanel', () => { }) render( - + ) const loginBtn = screen.getByTestId('github-login') as HTMLButtonElement diff --git a/src/components/SettingsPanel.tsx b/src/components/SettingsPanel.tsx index 1fcdd002..06270e54 100644 --- a/src/components/SettingsPanel.tsx +++ b/src/components/SettingsPanel.tsx @@ -1,13 +1,15 @@ import { useState, useRef, useCallback } from 'react' -import { X, Eye, EyeSlash, GithubLogo, SignOut } from '@phosphor-icons/react' +import { X, Eye, EyeSlash, GithubLogo, SignOut, Check, Plus } from '@phosphor-icons/react' import { GitHubDeviceFlow } from './GitHubDeviceFlow' -import type { Settings } from '../types' +import type { Settings, ThemeFile } from '../types' +import type { ThemeManager } from '../hooks/useThemeManager' interface SettingsPanelProps { open: boolean settings: Settings onSave: (settings: Settings) => void onClose: () => void + themeManager: ThemeManager } @@ -113,12 +115,12 @@ function GitHubConnectedRow({ username, onDisconnect }: { username: string; onDi // --- Settings Panel --- -export function SettingsPanel({ open, settings, onSave, onClose }: SettingsPanelProps) { +export function SettingsPanel({ open, settings, onSave, onClose, themeManager }: SettingsPanelProps) { if (!open) return null - return + return } -function SettingsPanelInner({ settings, onSave, onClose }: Omit) { +function SettingsPanelInner({ settings, onSave, onClose, themeManager }: Omit) { const [anthropicKey, setAnthropicKey] = useState(settings.anthropic_key ?? '') const [openaiKey, setOpenaiKey] = useState(settings.openai_key ?? '') const [googleKey, setGoogleKey] = useState(settings.google_key ?? '') @@ -183,6 +185,7 @@ function SettingsPanelInner({ settings, onSave, onClose }: Omit @@ -216,6 +219,7 @@ interface SettingsBodyProps { onGitHubConnected: (token: string, username: string) => void onGitHubDisconnect: () => void pullInterval: number; setPullInterval: (v: number) => void + themeManager: ThemeManager } function SettingsBody(props: SettingsBodyProps) { @@ -274,10 +278,81 @@ function SettingsBody(props: SettingsBodyProps) { + +
+ +
) } +// --- Appearance Section --- + +function ColorSwatch({ color }: { color: string }) { + return ( +
+ ) +} + +function ThemeCard({ theme, active, onSelect }: { theme: ThemeFile; active: boolean; onSelect: () => void }) { + const swatchColors = ['background', 'foreground', 'primary', 'border', 'muted'] + return ( + + ) +} + +function AppearanceSection({ themeManager }: { themeManager: ThemeManager }) { + const { themes, activeThemeId, switchTheme, createTheme } = themeManager + return ( + <> +
+
Appearance
+
+ Choose a theme for your vault. Themes are stored in _themes/ and synced with Git. +
+
+
+ {themes.map(theme => ( + switchTheme(theme.id)} /> + ))} +
+ + + ) +} + function SettingsFooter({ onClose, onSave }: { onClose: () => void; onSave: () => void }) { return (
void canGoBack?: boolean canGoForward?: boolean + themes?: ThemeFile[] + activeThemeId?: string | null + onSwitchTheme?: (themeId: string) => void + onCreateTheme?: () => void } /** Sets up keyboard shortcuts, command registry, menu events, and keyboard navigation. */ @@ -107,6 +111,10 @@ export function useAppCommands(config: AppCommandsConfig): CommandAction[] { onGoForward: config.onGoForward, canGoBack: config.canGoBack, canGoForward: config.canGoForward, + themes: config.themes, + activeThemeId: config.activeThemeId, + onSwitchTheme: config.onSwitchTheme, + onCreateTheme: config.onCreateTheme, }) useKeyboardNavigation({ diff --git a/src/hooks/useCommandRegistry.test.ts b/src/hooks/useCommandRegistry.test.ts index e22d541c..4a7cd8cb 100644 --- a/src/hooks/useCommandRegistry.test.ts +++ b/src/hooks/useCommandRegistry.test.ts @@ -261,6 +261,78 @@ describe('useCommandRegistry', () => { expect(eventCmds).toHaveLength(1) }) }) + + describe('theme commands', () => { + const themeFixtures = [ + { id: 'default', name: 'Default', description: '', colors: {}, typography: {}, spacing: {} }, + { id: 'dark', name: 'Dark', description: '', colors: {}, typography: {}, spacing: {} }, + ] + + it('generates switch-theme commands for each theme', () => { + const { result } = renderHook(() => useCommandRegistry(makeConfig({ + themes: themeFixtures, activeThemeId: 'default', onSwitchTheme: vi.fn(), + }))) + const switchDefault = result.current.find(c => c.id === 'switch-theme-default') + const switchDark = result.current.find(c => c.id === 'switch-theme-dark') + expect(switchDefault).toBeDefined() + expect(switchDefault!.label).toBe('Switch to Default Theme') + expect(switchDefault!.group).toBe('Appearance') + expect(switchDark).toBeDefined() + expect(switchDark!.label).toBe('Switch to Dark Theme') + }) + + it('disables switch command for the currently active theme', () => { + const { result } = renderHook(() => useCommandRegistry(makeConfig({ + themes: themeFixtures, activeThemeId: 'default', onSwitchTheme: vi.fn(), + }))) + expect(result.current.find(c => c.id === 'switch-theme-default')!.enabled).toBe(false) + expect(result.current.find(c => c.id === 'switch-theme-dark')!.enabled).toBe(true) + }) + + it('calls onSwitchTheme when switch command executes', () => { + const onSwitchTheme = vi.fn() + const { result } = renderHook(() => useCommandRegistry(makeConfig({ + themes: themeFixtures, activeThemeId: 'default', onSwitchTheme, + }))) + result.current.find(c => c.id === 'switch-theme-dark')!.execute() + expect(onSwitchTheme).toHaveBeenCalledWith('dark') + }) + + it('includes new-theme command when onCreateTheme is provided', () => { + const onCreateTheme = vi.fn() + const { result } = renderHook(() => useCommandRegistry(makeConfig({ + themes: themeFixtures, activeThemeId: 'default', onCreateTheme, + }))) + const newTheme = result.current.find(c => c.id === 'new-theme') + expect(newTheme).toBeDefined() + expect(newTheme!.group).toBe('Appearance') + expect(newTheme!.enabled).toBe(true) + }) + + it('omits new-theme command when onCreateTheme is not provided', () => { + const { result } = renderHook(() => useCommandRegistry(makeConfig({ + themes: themeFixtures, activeThemeId: 'default', + }))) + expect(result.current.find(c => c.id === 'new-theme')).toBeUndefined() + }) + + it('calls onCreateTheme when new-theme executes', () => { + const onCreateTheme = vi.fn() + const { result } = renderHook(() => useCommandRegistry(makeConfig({ + themes: themeFixtures, activeThemeId: 'default', onCreateTheme, + }))) + result.current.find(c => c.id === 'new-theme')!.execute() + expect(onCreateTheme).toHaveBeenCalled() + }) + + it('includes Appearance in command groups', () => { + const { result } = renderHook(() => useCommandRegistry(makeConfig({ + themes: themeFixtures, activeThemeId: 'default', onSwitchTheme: vi.fn(), + }))) + const groups = new Set(result.current.map(c => c.group)) + expect(groups).toContain('Appearance') + }) + }) }) describe('pluralizeType', () => { @@ -345,6 +417,7 @@ describe('groupSortKey', () => { expect(groupSortKey('Navigation')).toBeLessThan(groupSortKey('Note')) expect(groupSortKey('Note')).toBeLessThan(groupSortKey('Git')) expect(groupSortKey('Git')).toBeLessThan(groupSortKey('View')) - expect(groupSortKey('View')).toBeLessThan(groupSortKey('Settings')) + expect(groupSortKey('View')).toBeLessThan(groupSortKey('Appearance')) + expect(groupSortKey('Appearance')).toBeLessThan(groupSortKey('Settings')) }) }) diff --git a/src/hooks/useCommandRegistry.ts b/src/hooks/useCommandRegistry.ts index a4dc4dc4..6cdb5347 100644 --- a/src/hooks/useCommandRegistry.ts +++ b/src/hooks/useCommandRegistry.ts @@ -1,8 +1,8 @@ import { useMemo } from 'react' -import type { SidebarSelection, VaultEntry } from '../types' +import type { SidebarSelection, ThemeFile, VaultEntry } from '../types' import type { ViewMode } from './useViewMode' -export type CommandGroup = 'Navigation' | 'Note' | 'Git' | 'View' | 'Settings' +export type CommandGroup = 'Navigation' | 'Note' | 'Git' | 'View' | 'Appearance' | 'Settings' export interface CommandAction { id: string @@ -40,6 +40,10 @@ interface CommandRegistryConfig { onGoForward?: () => void canGoBack?: boolean canGoForward?: boolean + themes?: ThemeFile[] + activeThemeId?: string | null + onSwitchTheme?: (themeId: string) => void + onCreateTheme?: () => void } const PLURAL_OVERRIDES: Record = { @@ -65,7 +69,7 @@ export function extractVaultTypes(entries: VaultEntry[]): string[] { return Array.from(typeSet).sort() } -const GROUP_ORDER: CommandGroup[] = ['Navigation', 'Note', 'Git', 'View', 'Settings'] +const GROUP_ORDER: CommandGroup[] = ['Navigation', 'Note', 'Git', 'View', 'Appearance', 'Settings'] export function groupSortKey(group: CommandGroup): number { return GROUP_ORDER.indexOf(group) @@ -94,6 +98,29 @@ export function buildTypeCommands( }) } +export function buildThemeCommands( + themes: ThemeFile[] | undefined, + activeThemeId: string | null | undefined, + onSwitchTheme: ((themeId: string) => void) | undefined, + onCreateTheme: (() => 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), + })) + if (onCreateTheme) { + switchCmds.push({ + id: 'new-theme', label: 'New Theme', group: 'Appearance' as CommandGroup, + keywords: ['theme', 'create', 'appearance'], enabled: true, execute: onCreateTheme, + }) + } + return switchCmds +} + export function useCommandRegistry(config: CommandRegistryConfig): CommandAction[] { const { activeTabPath, entries, modifiedCount, @@ -103,6 +130,7 @@ export function useCommandRegistry(config: CommandRegistryConfig): CommandAction onZoomIn, onZoomOut, onZoomReset, zoomLevel, onSelect, onCloseTab, onGoBack, onGoForward, canGoBack, canGoForward, + themes, activeThemeId, onSwitchTheme, onCreateTheme, } = config const hasActiveNote = activeTabPath !== null @@ -151,6 +179,9 @@ export function useCommandRegistry(config: CommandRegistryConfig): CommandAction { id: 'zoom-out', label: `Zoom Out (${zoomLevel}%)`, group: 'View', shortcut: '⌘-', keywords: ['zoom', 'smaller', 'scale'], enabled: zoomLevel > 80, execute: onZoomOut }, { id: 'zoom-reset', label: 'Reset Zoom', group: 'View', shortcut: '⌘0', keywords: ['zoom', 'actual', 'default', '100'], enabled: zoomLevel !== 100, execute: onZoomReset }, + // Appearance + ...buildThemeCommands(themes, activeThemeId, onSwitchTheme, onCreateTheme), + // Settings { id: 'open-settings', label: 'Open Settings', group: 'Settings', shortcut: '⌘,', keywords: ['preferences', 'config'], enabled: true, execute: onOpenSettings }, @@ -167,6 +198,6 @@ export function useCommandRegistry(config: CommandRegistryConfig): CommandAction onZoomIn, onZoomOut, onZoomReset, zoomLevel, onSelect, onCloseTab, onGoBack, onGoForward, canGoBack, canGoForward, - vaultTypes, + vaultTypes, themes, activeThemeId, onSwitchTheme, onCreateTheme, ]) } diff --git a/src/hooks/useThemeManager.test.ts b/src/hooks/useThemeManager.test.ts new file mode 100644 index 00000000..9cda2ae2 --- /dev/null +++ b/src/hooks/useThemeManager.test.ts @@ -0,0 +1,245 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { renderHook, act, waitFor } from '@testing-library/react' +import type { ThemeFile, VaultSettings } 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 mockSettings: VaultSettings = { theme: 'default' } + +const mockInvokeFn = vi.fn(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 +}) + +vi.mock('@tauri-apps/api/core', () => ({ + invoke: vi.fn(), +})) + +vi.mock('../mock-tauri', () => ({ + isTauri: () => false, + mockInvoke: (cmd: string, args?: Record) => mockInvokeFn(cmd, args), +})) + +// Must import after mocks +const { useThemeManager } = await import('./useThemeManager') + +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 = '' + }) + + it('loads themes and active theme on mount', async () => { + const { result } = renderHook(() => useThemeManager('/vault')) + await waitFor(() => { + expect(result.current.themes).toHaveLength(2) + }) + expect(result.current.activeThemeId).toBe('default') + expect(result.current.activeTheme?.name).toBe('Default') + }) + + it('returns empty state when vaultPath is null', async () => { + const { result } = renderHook(() => useThemeManager(null)) + // Give it time to settle — should remain empty + 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')) + 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('#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') + }) + + it('switchTheme calls set_active_theme and updates activeThemeId', async () => { + const { result } = renderHook(() => useThemeManager('/vault')) + await waitFor(() => { + expect(result.current.themes).toHaveLength(2) + }) + + await act(async () => { + await result.current.switchTheme('dark') + }) + + expect(mockInvokeFn).toHaveBeenCalledWith('set_active_theme', { vault_path: '/vault', theme_id: 'dark' }) + expect(result.current.activeThemeId).toBe('dark') + }) + + 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') + + await act(async () => { + await result.current.switchTheme('dark') + }) + + await waitFor(() => { + expect(root.style.getPropertyValue('--background')).toBe('#0F0F23') + }) + expect(root.style.getPropertyValue('--foreground')).toBe('#E2E8F0') + }) + + 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', { vault_path: '/vault', source_id: '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) + }) + + await act(async () => { + await result.current.createTheme() + }) + + expect(mockInvokeFn).toHaveBeenCalledWith('create_theme', { vault_path: '/vault', source_id: null }) + }) + + it('handles load failure gracefully', async () => { + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) + mockInvokeFn.mockRejectedValueOnce(new Error('disk error')) + + const { result } = renderHook(() => useThemeManager('/vault')) + await new Promise(r => setTimeout(r, 50)) + + expect(result.current.themes).toHaveLength(0) + expect(result.current.activeThemeId).toBeNull() + expect(warnSpy).toHaveBeenCalledWith('Failed to load themes:', expect.any(Error)) + 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) + }) + + mockInvokeFn.mockRejectedValueOnce(new Error('permission denied')) + + await act(async () => { + await result.current.switchTheme('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)) + errorSpy.mockRestore() + }) + + it('switchTheme is a no-op when vaultPath is null', async () => { + const { result } = renderHook(() => useThemeManager(null)) + await act(async () => { + await result.current.switchTheme('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 = '' + await act(async () => { + newId = await result.current.createTheme() + }) + expect(newId).toBe('') + }) + + it('reloadThemes re-fetches theme list', async () => { + const { result } = renderHook(() => useThemeManager('/vault')) + await waitFor(() => { + expect(result.current.themes).toHaveLength(2) + }) + + const initialListCalls = mockInvokeFn.mock.calls.filter(c => c[0] === 'list_themes').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) + }) +}) diff --git a/src/hooks/useThemeManager.ts b/src/hooks/useThemeManager.ts new file mode 100644 index 00000000..4b696e7c --- /dev/null +++ b/src/hooks/useThemeManager.ts @@ -0,0 +1,118 @@ +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(command: string, args: Record): Promise { + return isTauri() ? invoke(command, args) : mockInvoke(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 + createTheme: (sourceId?: 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([]) + const [activeThemeId, setActiveThemeId] = useState(null) + const prevThemeRef = useRef(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('list_themes', { vault_path: vaultPath }), + tauriCall('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('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('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 } +} diff --git a/src/mock-tauri/mock-handlers.ts b/src/mock-tauri/mock-handlers.ts index d547cdc8..44483b82 100644 --- a/src/mock-tauri/mock-handlers.ts +++ b/src/mock-tauri/mock-handlers.ts @@ -3,7 +3,7 @@ * Each handler simulates a Tauri backend command. */ -import type { VaultEntry, ModifiedFile, Settings, DeviceFlowStart, DeviceFlowPollResult, GitHubUser, GitPullResult, LastCommitInfo } from '../types' +import type { VaultEntry, ModifiedFile, Settings, DeviceFlowStart, DeviceFlowPollResult, GitHubUser, GitPullResult, LastCommitInfo, ThemeFile, VaultSettings } from '../types' import { MOCK_CONTENT } from './mock-content' import { MOCK_ENTRIES } from './mock-entries' @@ -82,6 +82,29 @@ let mockSettings: Settings = { auto_pull_interval_minutes: 5, } +let mockVaultSettings: VaultSettings = { theme: null } + +const mockThemes: ThemeFile[] = [ + { + id: 'default', name: 'Default', description: 'Light theme with warm, paper-like tones', + colors: { background: '#FFFFFF', foreground: '#37352F', primary: '#155DFF', 'sidebar-background': '#F7F6F3', border: '#E9E9E7', muted: '#F0F0EF' }, + typography: { 'font-family': "'Inter', -apple-system, BlinkMacSystemFont, sans-serif", 'font-size-base': '14px' }, + spacing: { 'sidebar-width': '250px' }, + }, + { + id: 'dark', name: 'Dark', description: 'Dark variant with deep navy tones', + colors: { background: '#0f0f1a', foreground: '#e0e0e0', primary: '#155DFF', 'sidebar-background': '#1a1a2e', border: '#2a2a4a', muted: '#1e1e3a' }, + typography: { 'font-family': "'Inter', -apple-system, BlinkMacSystemFont, sans-serif", 'font-size-base': '14px' }, + spacing: { 'sidebar-width': '250px' }, + }, + { + id: 'minimal', name: 'Minimal', description: 'High contrast, minimal chrome', + colors: { background: '#FAFAFA', foreground: '#111111', primary: '#000000', 'sidebar-background': '#F5F5F5', border: '#E0E0E0', muted: '#F5F5F5' }, + typography: { 'font-family': "'SF Mono', 'Menlo', monospace", 'font-size-base': '13px' }, + spacing: { 'sidebar-width': '220px' }, + }, +] + let mockDeviceFlowPollCount = 0 function handleAiChat(args: { request: { messages: { role: string; content: string }[]; model?: string; system?: string } }) { @@ -240,6 +263,22 @@ export const mockHandlers: Record any> = { }, create_getting_started_vault: () => '/Users/mock/Documents/Laputa', register_mcp_tools: () => 'registered', + list_themes: (): ThemeFile[] => [...mockThemes], + get_theme: (args: { theme_id: string }): ThemeFile => { + const t = mockThemes.find(t => t.id === args.theme_id) + if (!t) throw new Error(`Theme not found: ${args.theme_id}`) + return { ...t } + }, + get_vault_settings: (): VaultSettings => ({ ...mockVaultSettings }), + save_vault_settings: (args: { settings: VaultSettings }) => { mockVaultSettings = { ...args.settings }; return null }, + set_active_theme: (args: { theme_id: string }) => { mockVaultSettings.theme = args.theme_id; return null }, + create_theme: (args: { source_id?: string }): string => { + const sourceId = args.source_id ?? 'default' + const source = mockThemes.find(t => t.id === sourceId) ?? mockThemes[0] + const newId = `untitled-${mockThemes.length}` + mockThemes.push({ ...source, id: newId, name: 'Untitled Theme' }) + return newId + }, } export function addMockEntry(_entry: VaultEntry, content: string): void { diff --git a/src/types.ts b/src/types.ts index bac49ff5..41a9a020 100644 --- a/src/types.ts +++ b/src/types.ts @@ -115,6 +115,19 @@ export interface SearchResponse { export type SearchMode = 'keyword' | 'semantic' | 'hybrid' +export interface ThemeFile { + id: string + name: string + description: string + colors: Record + typography: Record + spacing: Record +} + +export interface VaultSettings { + theme: string | null +} + export type SidebarSelection = | { kind: 'filter'; filter: 'all' | 'favorites' | 'archived' | 'trash' | 'changes' } | { kind: 'sectionGroup'; type: string }