From 54c0efa3e45e68eb422385411d1a801cf886e5bd Mon Sep 17 00:00:00 2001 From: lucaronin Date: Fri, 24 Apr 2026 22:26:07 +0200 Subject: [PATCH] feat: add dark mode foundation --- docs/ABSTRACTIONS.md | 8 +- docs/ARCHITECTURE.md | 11 +- docs/GETTING-STARTED.md | 12 +- docs/adr/0013-remove-theming-system.md | 3 +- .../0081-internal-light-dark-theme-runtime.md | 43 ++ docs/adr/README.md | 3 +- index.html | 45 +- src-tauri/src/settings.rs | 87 ++-- src/App.css | 6 +- src/App.tsx | 2 + src/components/AiActionCard.test.tsx | 4 +- src/components/AiActionCard.tsx | 156 +++++-- src/components/AiAgentsOnboardingPrompt.tsx | 14 +- src/components/AiPanel.tsx | 2 +- src/components/AiPanelChrome.tsx | 2 +- src/components/BreadcrumbBar.tsx | 4 +- src/components/BulkActionBar.tsx | 6 +- src/components/ClaudeCodeOnboardingPrompt.tsx | 6 +- src/components/ConflictResolverModal.tsx | 399 +++++++++++++----- src/components/CreateNoteDialog.tsx | 2 +- src/components/DiffView.test.tsx | 10 +- src/components/DiffView.tsx | 6 +- src/components/Editor.css | 18 +- src/components/EditorTheme.css | 2 +- src/components/FeedbackDialog.tsx | 15 +- src/components/LinuxTitlebar.tsx | 2 +- src/components/PulseView.tsx | 6 +- src/components/RawEditorView.tsx | 392 ++++++++++++----- src/components/SettingsPanel.test.tsx | 69 +++ src/components/SettingsPanel.tsx | 108 ++++- src/components/SidebarParts.tsx | 40 +- src/components/SingleEditorView.test.tsx | 19 + src/components/SingleEditorView.tsx | 4 +- src/components/StatusDropdown.tsx | 82 ++-- src/components/TagsDropdown.tsx | 225 ++++++---- src/components/TelemetryConsentDialog.tsx | 20 +- src/components/TypeSelector.tsx | 2 +- src/components/UpdateBanner.tsx | 25 +- src/components/WelcomeScreen.tsx | 10 +- src/components/WikilinkSuggestionMenu.css | 8 +- src/components/folder-tree/FolderItemRow.tsx | 2 +- .../note-item/ChangeNoteContent.tsx | 14 +- src/components/note-list/FilterPills.tsx | 2 +- src/components/note-list/InboxFilterPills.tsx | 2 +- src/components/propertyDropdownUtils.test.ts | 28 ++ src/components/propertyDropdownUtils.ts | 57 +++ src/components/status-bar/StatusBarBadges.tsx | 10 +- src/components/status-bar/VaultMenu.tsx | 2 +- src/components/ui/badge.tsx | 2 +- src/components/ui/button.tsx | 2 +- src/extensions/frontmatterHighlight.ts | 9 +- src/extensions/markdownHighlight.ts | 34 +- src/hooks/useCodeMirror.ts | 31 +- src/hooks/useDocumentThemeMode.test.ts | 29 ++ src/hooks/useDocumentThemeMode.ts | 33 ++ src/hooks/useSettings.test.ts | 3 + src/hooks/useSettings.ts | 3 + src/hooks/useThemeMode.test.ts | 49 +++ src/hooks/useThemeMode.ts | 30 ++ src/index.css | 360 +++++++++++++--- src/lib/themeMode.test.ts | 66 +++ src/lib/themeMode.ts | 66 +++ src/main.tsx | 3 + src/mock-tauri/mock-handlers.coverage.test.ts | 1 + src/mock-tauri/mock-handlers.more.test.ts | 16 + src/mock-tauri/mock-handlers.ts | 2 + src/theme.json | 2 +- src/types.ts | 2 + src/utils/releaseDownloadPage.test.ts | 3 + src/utils/releaseDownloadPage.ts | 51 ++- src/utils/releaseHistoryPage.test.ts | 3 + src/utils/releaseHistoryPage.ts | 119 ++++-- 72 files changed, 2203 insertions(+), 711 deletions(-) create mode 100644 docs/adr/0081-internal-light-dark-theme-runtime.md create mode 100644 src/components/propertyDropdownUtils.test.ts create mode 100644 src/components/propertyDropdownUtils.ts create mode 100644 src/hooks/useDocumentThemeMode.test.ts create mode 100644 src/hooks/useDocumentThemeMode.ts create mode 100644 src/hooks/useThemeMode.test.ts create mode 100644 src/hooks/useThemeMode.ts create mode 100644 src/lib/themeMode.test.ts create mode 100644 src/lib/themeMode.ts diff --git a/docs/ABSTRACTIONS.md b/docs/ABSTRACTIONS.md index 662d7b7d..b5ed72f4 100644 --- a/docs/ABSTRACTIONS.md +++ b/docs/ABSTRACTIONS.md @@ -535,10 +535,11 @@ Typed ASCII arrow sequences are normalized consistently in both editor modes: ## Styling -The app uses a single light theme — the vault-based theming system was removed (see [ADR-0013](adr/0013-remove-theming-system.md)). Styling is defined in two layers: +The app uses internal light and dark themes owned by Tolaria (see [ADR-0081](adr/0081-internal-light-dark-theme-runtime.md)). The previous vault-authored theming system remains removed; theme mode is an installation-local app preference. -1. **Global CSS variables** (`src/index.css`): App-wide colors via `:root`, bridged to Tailwind v4 +1. **Global CSS variables** (`src/index.css`): Semantic app colors, borders, surfaces, and interaction states via `:root` / `[data-theme]`, bridged to Tailwind v4 2. **Editor theme** (`src/theme.json`): BlockNote typography, flattened to CSS vars by `useEditorTheme` +3. **Runtime theme bridge**: Applies `data-theme` and `.dark` for shadcn/ui, while CodeMirror and editor-specific consumers derive any non-CSS-variable values from the same semantic contract ## Inspector Abstraction @@ -648,11 +649,12 @@ interface Settings { analytics_enabled: boolean | null anonymous_id: string | null release_channel: string | null // null = stable default, "alpha" = every-push prerelease feed + theme_mode: 'light' | 'dark' | null default_ai_agent: 'claude_code' | 'codex' | null } ``` -Managed by `useSettings` hook and `SettingsPanel` component. `default_ai_agent` is an installation-local preference that selects which supported CLI agent the AI panel, command palette AI mode, and status bar should target by default. The AutoGit fields are also installation-local: `useAutoGit` consumes them to schedule automatic checkpoints, while `useCommitFlow` and the status bar quick action reuse the same checkpoint runner and deterministic automatic commit message generation. +Managed by `useSettings` hook and `SettingsPanel` component. `theme_mode` is installation-local because it controls device comfort rather than vault structure. `default_ai_agent` is an installation-local preference that selects which supported CLI agent the AI panel, command palette AI mode, and status bar should target by default. The AutoGit fields are also installation-local: `useAutoGit` consumes them to schedule automatic checkpoints, while `useCommitFlow` and the status bar quick action reuse the same checkpoint runner and deterministic automatic commit message generation. ## Telemetry diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index fa1e579b..e1b817ca 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -406,10 +406,11 @@ flowchart TD ## Styling -The app uses a single light theme with no user-configurable theming (see [ADR-0013](adr/0013-remove-theming-system.md)). +The app uses internal app-owned light and dark themes (see [ADR-0081](adr/0081-internal-light-dark-theme-runtime.md)). This is not the old vault-authored theming system from ADR-0013: users choose a mode, but themes are owned by the app. -1. **Global CSS variables** (`src/index.css`): App-wide colors, borders, backgrounds. Bridged to Tailwind v4 via `@theme inline`. -2. **Editor theme** (`src/theme.json`): BlockNote-specific typography. Flattened to CSS vars by `useEditorTheme`. +1. **Global CSS variables** (`src/index.css`): Semantic app colors, borders, surfaces, and interaction states. Bridged to Tailwind v4 via `@theme inline`. +2. **Editor theme** (`src/theme.json`): BlockNote-specific typography. Flattened to CSS vars by `useEditorTheme`; editor colors resolve through the same semantic app variables. +3. **Theme runtime**: Applies `data-theme` and the shadcn-compatible `.dark` class before React consumers render, with a localStorage mirror to avoid startup flash when dark mode is selected. ## Vault Management @@ -751,14 +752,14 @@ No Redux or global context. State lives in the root `App.tsx` and custom hooks: | `frontmatterOps` | — (pure functions) | Frontmatter CRUD: key→VaultEntry mapping, mock/Tauri dispatch | | `useTabManagement` | Navigation history, note switching | Note navigation lifecycle | | `useVaultSwitcher` | `vaultPath`, `extraVaults` | Vault switching | -| `useTheme` | Editor theme CSS vars | Editor typography theme | +| `useTheme` | Editor theme CSS vars and theme-mode bridge | Editor typography and app theme runtime | | `useCliAiAgent` | `messages`, `status`, tool actions | Selected AI agent conversation | | `useAutoSync` | Sync interval, pull/push state | Git auto-sync | | `useAutoGit` | Last activity timestamp, idle/inactive checkpoint triggers | Automatic commit/push checkpoints | | `useCommitFlow` | Commit dialog state, shared manual/automatic checkpoint runner | Git commit/push orchestration | | `useGitRemoteStatus` | `remoteStatus`, `refreshRemoteStatus()` | On-demand remote detection for commit UI | | `useUnifiedSearch` | Query, results, loading state | Keyword search | -| `useSettings` | App settings (telemetry, release channel, auto-sync interval, AutoGit thresholds, default AI agent) | Persistent settings | +| `useSettings` | App settings (telemetry, release channel, theme mode, auto-sync interval, AutoGit thresholds, default AI agent) | Persistent settings | | `useVaultConfig` | Per-vault UI preferences | Vault-specific config | | `appCommandDispatcher` | Canonical shortcut/menu command IDs | Shared execution path for renderer and native menu commands | diff --git a/docs/GETTING-STARTED.md b/docs/GETTING-STARTED.md index 5c0f7d21..fcfcd1a5 100644 --- a/docs/GETTING-STARTED.md +++ b/docs/GETTING-STARTED.md @@ -63,8 +63,8 @@ tolaria/ │ ├── App.css # App shell layout styles │ ├── types.ts # Shared TS types (VaultEntry, Settings, etc.) │ ├── mock-tauri.ts # Mock Tauri layer for browser testing -│ ├── theme.json # Editor theme configuration -│ ├── index.css # Global CSS variables + Tailwind setup +│ ├── theme.json # Editor typography theme configuration +│ ├── index.css # Semantic app theme variables + Tailwind setup │ │ │ ├── components/ # UI components (~98 files) │ │ ├── Sidebar.tsx # Left panel: filters + type groups @@ -287,14 +287,14 @@ tolaria/ | File | Why it matters | |------|---------------| -| `src/index.css` | All CSS custom properties. The design token source of truth. | -| `src/theme.json` | Editor-specific theme (fonts, headings, lists, code blocks). | +| `src/index.css` | Semantic CSS custom properties for app-owned light/dark themes. | +| `src/theme.json` | Editor-specific typography theme (fonts, headings, lists, code blocks). | ### Settings & Config | File | Why it matters | |------|---------------| -| `src/hooks/useSettings.ts` | App settings (telemetry, release channel, auto-sync interval, default AI agent). | +| `src/hooks/useSettings.ts` | App settings (telemetry, release channel, theme mode, auto-sync interval, default AI agent). | | `src/lib/releaseChannel.ts` | Normalizes persisted updater-channel values (`stable` default, optional `alpha`). | | `src/lib/appUpdater.ts` | Frontend wrapper for channel-aware updater commands. | | `src/hooks/useMainWindowSizeConstraints.ts` | Derives the main-window minimum width from the visible panes and asks Tauri to grow back to fit wider layouts. | @@ -404,7 +404,7 @@ BASE_URL="http://localhost:5173" npx playwright test tests/smoke/.spec.ts ### Modify styling -1. **Global CSS variables**: Edit `src/index.css` +1. **Global app/theme variables**: Edit `src/index.css` 2. **Editor typography**: Edit `src/theme.json` ### Work with the AI agent diff --git a/docs/adr/0013-remove-theming-system.md b/docs/adr/0013-remove-theming-system.md index 7e73a4eb..5bd2b92d 100644 --- a/docs/adr/0013-remove-theming-system.md +++ b/docs/adr/0013-remove-theming-system.md @@ -2,8 +2,9 @@ type: ADR id: "0013" title: "Remove vault-based theming system" -status: active +status: superseded date: 2026-03-23 +superseded_by: "0081" --- ## Context diff --git a/docs/adr/0081-internal-light-dark-theme-runtime.md b/docs/adr/0081-internal-light-dark-theme-runtime.md new file mode 100644 index 00000000..11620927 --- /dev/null +++ b/docs/adr/0081-internal-light-dark-theme-runtime.md @@ -0,0 +1,43 @@ +--- +type: ADR +id: "0081" +title: "Internal light and dark theme runtime" +status: active +date: 2026-04-24 +supersedes: "0013" +--- + +## Context + +ADR-0013 removed the vault-authored theming system and made Tolaria light-only. That kept the app simpler, but dark mode has become a product requirement for long writing sessions and accessibility. + +The previous theming system should not return in its old form: vault notes, live user-authored themes, and broad runtime editing created too much maintenance burden. Tolaria still needs a small app-owned theme architecture because the UI spans Tailwind/shadcn variables, BlockNote/Mantine surfaces, CodeMirror raw editing, syntax highlighting, and product-specific states such as selected rows, badges, warnings, and diff lines. + +## Decision + +**Tolaria will support internal app-owned light and dark themes through a semantic CSS-variable contract, with the user's theme mode persisted as installation-local app settings.** + +The v1 theme runtime is deliberately smaller than a general theming system: + +- Themes are defined by the app, not by vault-authored notes. +- CSS custom properties remain the public runtime contract for product components, Tailwind v4, and shadcn/ui. +- Typed TypeScript helpers may derive values for consumers that cannot read CSS variables directly, such as CodeMirror extensions. +- Existing CSS variables stay available as compatibility aliases while the UI migrates toward semantic names. +- The first persisted choices are `light` and `dark`; system-follow, high-contrast variants, custom themes, and per-vault themes are deferred. + +## Options considered + +- **Internal light/dark runtime with semantic tokens** (chosen): ships dark mode while keeping the product-owned theme surface small, testable, and compatible with existing CSS-variable usage. +- **Reintroduce vault-authored theme notes**: flexible, but repeats the complexity removed by ADR-0013 and makes dark mode dependent on user-editable data. +- **Ad hoc `.dark` overrides in components**: fastest initially, but would scatter color logic across the app and make future theme variants expensive. +- **Single TypeScript theme object as source of truth**: attractive for validation, but the current app already relies on CSS variables for Tailwind, shadcn/ui, BlockNote CSS overrides, and many product components. + +## Consequences + +- `src/index.css` owns the stable CSS custom-property contract for app chrome and shared states. +- `src/theme.json` continues to describe editor typography, but editor-facing colors should resolve through the same semantic CSS variables used by the app shell. +- `useTheme` remains responsible for editor theme flattening and can grow into the bridge between app theme mode and editor consumers. +- App settings, not vault frontmatter, store the selected theme mode because it is an installation-local comfort preference. +- Startup must avoid a light-mode flash when dark mode is selected, so the runtime needs a pre-React localStorage mirror and a minimal `index.html` prepaint style in addition to persisted Tauri settings. +- Domain tokens should be introduced only when a surface needs a role that generic semantic tokens cannot express clearly. +- Re-evaluate if Tolaria decides to support user-authored custom themes, per-vault themes, or system-synchronized mode as a first-class product requirement. diff --git a/docs/adr/README.md b/docs/adr/README.md index 89f85403..32afb778 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -68,7 +68,7 @@ proposed → active → superseded | [0010](0010-dynamic-wikilink-relationship-detection.md) | Dynamic wikilink relationship detection | active | | [0011](0011-mcp-server-for-ai-integration.md) | MCP server for AI tool integration | superseded → [0074](0074-explicit-external-ai-tool-setup-and-least-privilege-desktop-scope.md) | | [0012](0012-claude-cli-for-ai-agent.md) | Claude CLI subprocess for AI agent | active | -| [0013](0013-remove-theming-system.md) | Remove vault-based theming system | active | +| [0013](0013-remove-theming-system.md) | Remove vault-based theming system | superseded -> [0081](0081-internal-light-dark-theme-runtime.md) | | [0014](0014-git-based-vault-cache.md) | Git-based incremental vault cache | active | | [0015](0015-auto-save-with-debounce.md) | Auto-save with 500ms debounce | active | | [0016](0016-sentry-posthog-telemetry.md) | Sentry + PostHog telemetry with consent | active | @@ -136,3 +136,4 @@ proposed → active → superseded | [0078](0078-scoped-unsigned-fallback-for-app-managed-git-commits.md) | Scoped unsigned fallback for app-managed git commits | active | | [0079](0079-linux-window-chrome-and-menu-reuse.md) | Linux window chrome and menu reuse | active | | [0080](0080-cross-platform-desktop-release-artifacts-and-portable-vault-names.md) | Cross-platform desktop release artifacts and portable vault names | active | +| [0081](0081-internal-light-dark-theme-runtime.md) | Internal light and dark theme runtime | active | diff --git a/index.html b/index.html index 32d4b954..74876dea 100644 --- a/index.html +++ b/index.html @@ -7,17 +7,48 @@ + Tolaria
diff --git a/src-tauri/src/settings.rs b/src-tauri/src/settings.rs index 4705a70b..9431272b 100644 --- a/src-tauri/src/settings.rs +++ b/src-tauri/src/settings.rs @@ -5,7 +5,7 @@ use std::path::PathBuf; const APP_CONFIG_DIR: &str = "com.tolaria.app"; const LEGACY_APP_CONFIG_DIR: &str = "com.laputa.app"; -#[derive(Debug, Clone, Serialize, Deserialize, Default)] +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)] pub struct Settings { pub auto_pull_interval_minutes: Option, pub autogit_enabled: Option, @@ -17,6 +17,7 @@ pub struct Settings { pub analytics_enabled: Option, pub anonymous_id: Option, pub release_channel: Option, + pub theme_mode: Option, pub initial_h1_auto_rename_enabled: Option, pub default_ai_agent: Option, } @@ -53,6 +54,13 @@ pub fn normalize_default_ai_agent(value: Option<&str>) -> Option { } } +pub fn normalize_theme_mode(value: Option<&str>) -> Option { + match value.map(|candidate| candidate.trim().to_ascii_lowercase()) { + Some(mode) if mode == "light" || mode == "dark" => Some(mode), + _ => None, + } +} + fn normalize_settings(settings: Settings) -> Settings { Settings { auto_pull_interval_minutes: settings.auto_pull_interval_minutes, @@ -69,6 +77,7 @@ fn normalize_settings(settings: Settings) -> Settings { analytics_enabled: settings.analytics_enabled, anonymous_id: normalize_optional_string(settings.anonymous_id), release_channel: normalize_release_channel(settings.release_channel.as_deref()), + theme_mode: normalize_theme_mode(settings.theme_mode.as_deref()), initial_h1_auto_rename_enabled: settings.initial_h1_auto_rename_enabled, default_ai_agent: normalize_default_ai_agent(settings.default_ai_agent.as_deref()), } @@ -166,43 +175,8 @@ pub fn set_last_vault(vault_path: &str) -> Result<(), String> { mod tests { use super::*; - type SettingsSnapshot<'a> = ( - Option, - Option, - Option, - Option, - Option, - Option, - Option, - Option, - Option<&'a str>, - Option<&'a str>, - Option, - Option<&'a str>, - ); - - fn settings_snapshot(settings: &Settings) -> SettingsSnapshot<'_> { - ( - settings.auto_pull_interval_minutes, - settings.autogit_enabled, - settings.autogit_idle_threshold_seconds, - settings.autogit_inactive_threshold_seconds, - settings.auto_advance_inbox_after_organize, - settings.telemetry_consent, - settings.crash_reporting_enabled, - settings.analytics_enabled, - settings.anonymous_id.as_deref(), - settings.release_channel.as_deref(), - settings.initial_h1_auto_rename_enabled, - settings.default_ai_agent.as_deref(), - ) - } - fn assert_empty_settings(settings: &Settings) { - assert_eq!( - settings_snapshot(settings), - (None, None, None, None, None, None, None, None, None, None, None, None) - ); + assert_eq!(settings, &Settings::default()); } /// Helper: save settings to a temp file and reload them. @@ -244,12 +218,13 @@ mod tests { analytics_enabled: Some(false), anonymous_id: Some("abc-123-uuid".to_string()), release_channel: Some("alpha".to_string()), + theme_mode: Some("dark".to_string()), initial_h1_auto_rename_enabled: Some(false), default_ai_agent: Some("codex".to_string()), }; let json = serde_json::to_string(&settings).unwrap(); let parsed: Settings = serde_json::from_str(&json).unwrap(); - assert_eq!(settings_snapshot(&parsed), settings_snapshot(&settings)); + assert_eq!(parsed, settings); } #[test] @@ -269,6 +244,7 @@ mod tests { autogit_inactive_threshold_seconds: Some(30), auto_advance_inbox_after_organize: Some(true), release_channel: Some("alpha".to_string()), + theme_mode: Some("dark".to_string()), initial_h1_auto_rename_enabled: Some(false), default_ai_agent: Some("codex".to_string()), ..Default::default() @@ -279,6 +255,7 @@ mod tests { assert_eq!(loaded.autogit_inactive_threshold_seconds, Some(30)); assert_eq!(loaded.auto_advance_inbox_after_organize, Some(true)); assert_eq!(loaded.release_channel.as_deref(), Some("alpha")); + assert_eq!(loaded.theme_mode.as_deref(), Some("dark")); assert_eq!(loaded.initial_h1_auto_rename_enabled, Some(false)); assert_eq!(loaded.default_ai_agent.as_deref(), Some("codex")); } @@ -288,11 +265,13 @@ mod tests { let loaded = save_and_reload(Settings { anonymous_id: Some(" test-uuid ".to_string()), release_channel: Some(" alpha ".to_string()), + theme_mode: Some(" dark ".to_string()), default_ai_agent: Some(" codex ".to_string()), ..Default::default() }); assert_eq!(loaded.anonymous_id.as_deref(), Some("test-uuid")); assert_eq!(loaded.release_channel.as_deref(), Some("alpha")); + assert_eq!(loaded.theme_mode.as_deref(), Some("dark")); assert_eq!(loaded.default_ai_agent.as_deref(), Some("codex")); } @@ -334,6 +313,15 @@ mod tests { assert!(loaded.default_ai_agent.is_none()); } + #[test] + fn test_invalid_theme_mode_is_filtered() { + let loaded = save_and_reload(Settings { + theme_mode: Some("system".to_string()), + ..Default::default() + }); + assert!(loaded.theme_mode.is_none()); + } + #[test] fn test_get_settings_normalizes_legacy_beta_channel() { let dir = tempfile::TempDir::new().unwrap(); @@ -384,21 +372,14 @@ mod tests { ..Default::default() }); assert_eq!( - settings_snapshot(&loaded), - ( - None, - None, - None, - None, - None, - Some(true), - Some(true), - Some(false), - Some("test-uuid-v4"), - None, - None, - None, - ) + loaded, + Settings { + telemetry_consent: Some(true), + crash_reporting_enabled: Some(true), + analytics_enabled: Some(false), + anonymous_id: Some("test-uuid-v4".to_string()), + ..Default::default() + } ); } diff --git a/src/App.css b/src/App.css index 9fb14521..fec7e946 100644 --- a/src/App.css +++ b/src/App.css @@ -68,9 +68,9 @@ /* AI highlight animation — applied by useAiActivity when MCP calls ui_highlight */ @keyframes ai-highlight-glow { - 0% { box-shadow: inset 0 0 0 2px rgba(99, 102, 241, 0.8); } - 50% { box-shadow: inset 0 0 8px 2px rgba(99, 102, 241, 0.4); } - 100% { box-shadow: inset 0 0 0 2px rgba(99, 102, 241, 0); } + 0% { box-shadow: inset 0 0 0 2px color-mix(in srgb, var(--accent-blue) 80%, transparent); } + 50% { box-shadow: inset 0 0 8px 2px color-mix(in srgb, var(--accent-blue) 40%, transparent); } + 100% { box-shadow: inset 0 0 0 2px color-mix(in srgb, var(--accent-blue) 0%, transparent); } } .ai-highlight { diff --git a/src/App.tsx b/src/App.tsx index 1cbb8ed2..8000bff2 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -31,6 +31,7 @@ import { useAutoGit } from './hooks/useAutoGit' import { useVaultLoader } from './hooks/useVaultLoader' import { useAiAgentPreferences } from './hooks/useAiAgentPreferences' import { useSettings } from './hooks/useSettings' +import { useThemeMode } from './hooks/useThemeMode' import { useNoteActions } from './hooks/useNoteActions' import { planNewTypeCreation } from './hooks/useNoteCreation' import { useCommitFlow } from './hooks/useCommitFlow' @@ -367,6 +368,7 @@ function App() { }) }, [updateConfig, vaultConfig.inbox?.noteListProperties]) const { settings, loaded: settingsLoaded, saveSettings } = useSettings() + useThemeMode(settings.theme_mode, settingsLoaded) const aiAgentPreferences = useAiAgentPreferences({ settings, saveSettings, diff --git a/src/components/AiActionCard.test.tsx b/src/components/AiActionCard.test.tsx index 21b2ca92..a6d06e3e 100644 --- a/src/components/AiActionCard.test.tsx +++ b/src/components/AiActionCard.test.tsx @@ -69,13 +69,13 @@ describe('AiActionCard', () => { it('uses lighter background for open_note tool', () => { render() const card = screen.getByTestId('ai-action-card') - expect(card.style.background).toContain('0.06') + expect(card.style.background).toBe('var(--accent-blue-light)') }) it('uses standard background for vault tools', () => { render() const card = screen.getByTestId('ai-action-card') - expect(card.style.background).toContain('0.1') + expect(card.style.background).toBe('var(--accent-blue-bg)') }) // --- Expand / collapse --- diff --git a/src/components/AiActionCard.tsx b/src/components/AiActionCard.tsx index 817844df..cfec8f56 100644 --- a/src/components/AiActionCard.tsx +++ b/src/components/AiActionCard.tsx @@ -20,6 +20,10 @@ export interface AiActionCardProps { } const MAX_DETAIL_LENGTH = 800 +const DEFAULT_ACTION_CARD_BACKGROUND = 'var(--accent-blue-bg)' +const TOOL_BACKGROUND_MAP: Record = { + open_note: 'var(--accent-blue-light)', +} type IconRenderer = (size: number) => ReactNode @@ -66,6 +70,71 @@ function formatInputForDisplay(raw: string): string { } } +function hasActionDetails(input?: string, output?: string): boolean { + return Boolean(input || output) +} + +function resolveDirectOpenPath({ + hasDetails, + onOpenNote, + path, +}: Pick & { + hasDetails: boolean +}): string | null { + if (hasDetails || !path || !onOpenNote) return null + return path +} + +function ActionCardHeader({ + expanded, + hasDetails, + label, + onClick, + onKeyDown, + renderIcon, + status, +}: { + expanded: boolean + hasDetails: boolean + label: string + onClick: () => void + onKeyDown: (event: KeyboardEvent) => void + renderIcon: IconRenderer + status: AiActionStatus +}) { + return ( +
+ + + + {label} + +
+ ) +} + +function ActionIcon({ + expanded, + hasDetails, + renderIcon, +}: { + expanded: boolean + hasDetails: boolean + renderIcon: IconRenderer +}) { + if (!hasDetails) return <>{renderIcon(14)} + return expanded ? : +} + function DetailBlock({ label, content, isError }: { label: string; content: string; isError?: boolean }) { @@ -100,16 +169,41 @@ function DetailBlock({ label, content, isError }: { ) } -/** Whether this tool is a Tolaria UI-only tool (lighter styling). */ -function isUiOnlyTool(tool: string): boolean { - return tool === 'open_note' +function ActionCardDetails({ + expanded, + hasDetails, + input, + output, + status, +}: { + expanded: boolean + hasDetails: boolean + input?: string + output?: string + status: AiActionStatus +}) { + if (!expanded || !hasDetails) return null + + const formattedInput = input ? formatInputForDisplay(input) : undefined + return ( +
+ {formattedInput && } + {output && ( + + )} +
+ ) } export function AiActionCard({ tool, label, path, status, input, output, expanded, onToggle, onOpenNote, }: AiActionCardProps) { const renderIcon = TOOL_ICON_MAP[tool] ?? DEFAULT_ICON - const hasDetails = !!(input || output) + const hasDetails = hasActionDetails(input, output) + const directOpenPath = resolveDirectOpenPath({ path, onOpenNote, hasDetails }) const handleKeyDown = useCallback((e: KeyboardEvent) => { if (e.key === 'Enter' || e.key === ' ') { @@ -122,14 +216,13 @@ export function AiActionCard({ }, [onToggle, expanded]) const handleClick = useCallback(() => { - if (path && onOpenNote && !hasDetails) { - onOpenNote(path) - } else { - onToggle() + if (directOpenPath && onOpenNote) { + onOpenNote(directOpenPath) + return } - }, [path, onOpenNote, hasDetails, onToggle]) - const formattedInput = input ? formatInputForDisplay(input) : undefined + onToggle() + }, [directOpenPath, onOpenNote, onToggle]) return (
-
- - {hasDetails - ? (expanded ? : ) - : renderIcon(14)} - - {label} - -
- {expanded && hasDetails && ( -
- {formattedInput && } - {output && ( - - )} -
- )} + renderIcon={renderIcon} + status={status} + /> +
) } diff --git a/src/components/AiAgentsOnboardingPrompt.tsx b/src/components/AiAgentsOnboardingPrompt.tsx index c97f3247..a3484f72 100644 --- a/src/components/AiAgentsOnboardingPrompt.tsx +++ b/src/components/AiAgentsOnboardingPrompt.tsx @@ -19,7 +19,7 @@ interface AiAgentsOnboardingPromptProps { function getPromptCopy(statuses: AiAgentsStatus) { if (isAiAgentsStatusChecking(statuses)) { return { - accentClassName: 'bg-slate-100 text-slate-600', + accentClassName: 'bg-muted text-muted-foreground', description: 'Checking which AI agents are available on this machine.', icon: , title: 'Checking AI agents', @@ -28,7 +28,7 @@ function getPromptCopy(statuses: AiAgentsStatus) { if (!hasAnyInstalledAiAgent(statuses)) { return { - accentClassName: 'bg-amber-100 text-amber-700', + accentClassName: 'bg-[var(--feedback-warning-bg)] text-[var(--feedback-warning-text)]', description: 'Tolaria works best with a local CLI AI agent installed.', icon: , title: 'No AI agents detected', @@ -36,7 +36,7 @@ function getPromptCopy(statuses: AiAgentsStatus) { } return { - accentClassName: 'bg-emerald-100 text-emerald-700', + accentClassName: 'bg-[var(--feedback-success-bg)] text-[var(--feedback-success-text)]', description: 'Your AI agents are ready to use in Tolaria.', icon: , title: 'AI agents ready', @@ -63,7 +63,7 @@ function AgentStatusList({ statuses }: { statuses: AiAgentsStatus }) { {ready ? 'Installed' : 'Missing'} @@ -106,11 +106,11 @@ export function AiAgentsOnboardingPrompt({ {showLegacyClaudeCompatibility ? (
-
Claude Code not detected
-

+

Claude Code not detected
+

Install Claude Code or continue without it.

diff --git a/src/components/AiPanel.tsx b/src/components/AiPanel.tsx index 21bd98b5..ac2a4134 100644 --- a/src/components/AiPanel.tsx +++ b/src/components/AiPanel.tsx @@ -89,7 +89,7 @@ export function AiPanelView({ style={{ outline: 'none', borderLeft: isActive - ? '2px solid var(--accent-blue, #3b82f6)' + ? '2px solid var(--accent-blue)' : '1px solid var(--border)', animation: isActive ? 'ai-border-pulse 2s ease-in-out infinite' : undefined, transition: 'border-color 0.3s ease', diff --git a/src/components/AiPanelChrome.tsx b/src/components/AiPanelChrome.tsx index 4f77fc17..628aab6e 100644 --- a/src/components/AiPanelChrome.tsx +++ b/src/components/AiPanelChrome.tsx @@ -223,7 +223,7 @@ export function AiPanelComposer({ const placeholder = getComposerPlaceholder(agentLabel, agentReady, legacyCopy, hasContext) const sendButtonStyle = { background: canSend ? 'var(--primary)' : 'var(--muted)', - color: canSend ? 'white' : 'var(--muted-foreground)', + color: canSend ? 'var(--primary-foreground)' : 'var(--muted-foreground)', borderRadius: 8, width: 32, height: 34, diff --git a/src/components/BreadcrumbBar.tsx b/src/components/BreadcrumbBar.tsx index 11bdd0cf..27c60716 100644 --- a/src/components/BreadcrumbBar.tsx +++ b/src/components/BreadcrumbBar.tsx @@ -185,7 +185,7 @@ function FavoriteAction({ favorite, onToggleFavorite }: { favorite: boolean; onT return ( , title: 'Claude Code detected', @@ -23,7 +23,7 @@ function getPromptCopy(status: ClaudeCodeStatus) { if (status === 'missing') { return { - accentClassName: 'bg-amber-100 text-amber-700', + accentClassName: 'bg-[var(--feedback-warning-bg)] text-[var(--feedback-warning-text)]', description: 'Tolaria works best with an AI coding agent installed.', icon: , title: 'Claude Code not detected', @@ -31,7 +31,7 @@ function getPromptCopy(status: ClaudeCodeStatus) { } return { - accentClassName: 'bg-slate-100 text-slate-600', + accentClassName: 'bg-muted text-muted-foreground', description: 'Checking whether Claude Code is available on this machine.', icon: , title: 'Checking for Claude Code', diff --git a/src/components/ConflictResolverModal.tsx b/src/components/ConflictResolverModal.tsx index 921b8da1..eebff15d 100644 --- a/src/components/ConflictResolverModal.tsx +++ b/src/components/ConflictResolverModal.tsx @@ -1,8 +1,44 @@ -import { useState, useEffect, useRef, useCallback } from 'react' +import { useCallback, useEffect, useRef, useState, type KeyboardEvent } from 'react' import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogDescription } from '@/components/ui/dialog' import { Button } from '@/components/ui/button' import { AlertTriangle, FileText, Check, Loader2 } from 'lucide-react' import type { ConflictFileState } from '../hooks/useConflictResolver' +import { cn } from '@/lib/utils' + +type ConflictResolutionStrategy = 'ours' | 'theirs' + +const BINARY_FILE_EXTENSIONS = [ + '.png', + '.jpg', + '.jpeg', + '.gif', + '.webp', + '.ico', + '.pdf', + '.zip', + '.tar', + '.gz', + '.mp3', + '.mp4', + '.wav', + '.ogg', + '.woff', + '.woff2', + '.ttf', + '.otf', + '.eot', +] + +const RESOLUTION_LABELS: Record, string> = { + manual: 'Edited manually', + ours: 'Keeping mine', + theirs: 'Keeping theirs', +} + +const RESOLUTION_SHORTCUTS: Record = { + k: 'ours', + t: 'theirs', +} interface ConflictResolverModalProps { open: boolean @@ -17,8 +53,8 @@ interface ConflictResolverModalProps { } function isBinaryFile(file: string): boolean { - const binaryExts = ['.png', '.jpg', '.jpeg', '.gif', '.webp', '.ico', '.pdf', '.zip', '.tar', '.gz', '.mp3', '.mp4', '.wav', '.ogg', '.woff', '.woff2', '.ttf', '.otf', '.eot'] - return binaryExts.some(ext => file.toLowerCase().endsWith(ext)) + const normalizedFile = file.toLowerCase() + return BINARY_FILE_EXTENSIONS.some(ext => normalizedFile.endsWith(ext)) } function fileName(path: string): string { @@ -27,10 +63,9 @@ function fileName(path: string): string { function ResolutionLabel({ resolution }: { resolution: ConflictFileState['resolution'] }) { if (!resolution) return null - const labels = { ours: 'Keeping mine', theirs: 'Keeping theirs', manual: 'Edited manually' } return ( - - {labels[resolution]} + + {RESOLUTION_LABELS[resolution]} ) } @@ -62,9 +97,11 @@ function ConflictFileRow({ role="row" tabIndex={0} onFocus={onFocus} - className={`flex items-center justify-between gap-2 rounded-md border px-3 py-2 transition-colors ${ - focused ? 'border-ring bg-accent/50' : 'border-border bg-background' - } ${resolved ? 'opacity-70' : ''}`} + className={cn( + 'flex items-center justify-between gap-2 rounded-md border px-3 py-2 transition-colors', + focused ? 'border-ring bg-accent/50' : 'border-border bg-background', + resolved && 'opacity-70', + )} data-testid={`conflict-file-${state.file}`} >
@@ -118,8 +155,214 @@ function ConflictFileRow({ ) } +function clampFocusIndex(index: number, fileCount: number): number { + if (fileCount === 0) return 0 + return Math.min(Math.max(index, 0), fileCount - 1) +} + +function useConflictFocus(fileCount: number) { + const [focusIdx, setFocusIdx] = useState(0) + const focusIdxRef = useRef(0) + const visibleFocusIdx = clampFocusIndex(focusIdx, fileCount) + + const syncFocusIdx = useCallback((nextIndex: number) => { + const clampedIndex = clampFocusIndex(nextIndex, fileCount) + setFocusIdx(clampedIndex) + focusIdxRef.current = clampedIndex + }, [fileCount]) + + const moveFocus = useCallback((offset: number) => { + const currentIndex = clampFocusIndex(focusIdxRef.current, fileCount) + syncFocusIdx(currentIndex + offset) + }, [fileCount, syncFocusIdx]) + + return { + focusIdx: visibleFocusIdx, + focusIdxRef, + moveFocus, + syncFocusIdx, + } +} + +function hasCommandModifier(event: KeyboardEvent): boolean { + return event.metaKey || event.ctrlKey +} + +function isNextRowKey(event: KeyboardEvent): boolean { + if (event.key === 'ArrowDown') return true + return event.key === 'Tab' && !event.shiftKey +} + +function isPreviousRowKey(event: KeyboardEvent): boolean { + if (event.key === 'ArrowUp') return true + return event.key === 'Tab' && event.shiftKey +} + +function handleNavigationKey(event: KeyboardEvent, moveFocus: (offset: number) => void): boolean { + if (isNextRowKey(event)) { + event.preventDefault() + moveFocus(1) + return true + } + + if (isPreviousRowKey(event)) { + event.preventDefault() + moveFocus(-1) + return true + } + + return false +} + +function handleResolutionShortcut( + event: KeyboardEvent, + file: ConflictFileState | undefined, + onResolveFile: ConflictResolverModalProps['onResolveFile'], +): boolean { + const strategy = RESOLUTION_SHORTCUTS[event.key.toLowerCase()] + if (!strategy || !file || file.resolving || hasCommandModifier(event)) return false + + event.preventDefault() + onResolveFile(file.file, strategy) + return true +} + +function handleOpenShortcut( + event: KeyboardEvent, + file: ConflictFileState | undefined, + onOpenInEditor: ConflictResolverModalProps['onOpenInEditor'], +): boolean { + if (event.key.toLowerCase() !== 'o' || !file || file.resolving || hasCommandModifier(event)) return false + if (isBinaryFile(file.file)) return false + + event.preventDefault() + onOpenInEditor(file.file) + return true +} + +function handleCommitShortcut({ + allResolved, + committing, + event, + onCommit, +}: { + allResolved: boolean + committing: boolean + event: KeyboardEvent + onCommit: ConflictResolverModalProps['onCommit'] +}): boolean { + if (event.key !== 'Enter' || !allResolved || committing) return false + + event.preventDefault() + onCommit() + return true +} + +function ConflictDialogHeader({ fileCount }: { fileCount: number }) { + return ( + +
+ + Resolve Merge Conflicts +
+ + {fileCount} file{fileCount !== 1 ? 's have' : ' has'} merge conflicts. Choose how to resolve each file. + +
+ ) +} + +function ConflictFileList({ + fileStates, + focusIdx, + onFocusRow, + onOpenInEditor, + onResolveFile, +}: { + fileStates: ConflictFileState[] + focusIdx: number + onFocusRow: (index: number) => void + onOpenInEditor: (file: string) => void + onResolveFile: (file: string, strategy: ConflictResolutionStrategy) => void +}) { + return ( +
+ {fileStates.map((state, index) => ( + onResolveFile(state.file, strategy)} + onOpenInEditor={() => onOpenInEditor(state.file)} + onFocus={() => onFocusRow(index)} + /> + ))} +
+ ) +} + +function CommitButtonContent({ committing }: { committing: boolean }) { + if (!committing) return 'Commit & continue' + return ( + <> + Committing… + + ) +} + +function ConflictDialogFooter({ + allResolved, + committing, + onClose, + onCommit, +}: { + allResolved: boolean + committing: boolean + onClose: () => void + onCommit: () => void +}) { + return ( + + + K = keep mine · T = keep theirs · O = open · Enter = commit + +
+ + +
+
+ ) +} + export function ConflictResolverModal({ open, + onClose, + ...contentProps +}: ConflictResolverModalProps) { + return ( + { if (!isOpen) onClose() }}> + {open ? ( + + ) : null} + + ) +} + +function ConflictResolverDialogContent({ fileStates, allResolved, committing, @@ -129,118 +372,54 @@ export function ConflictResolverModal({ onCommit, onClose, }: ConflictResolverModalProps) { - const [focusIdx, setFocusIdx] = useState(0) - const focusIdxRef = useRef(0) - const listRef = useRef(null) + const { + focusIdx, + focusIdxRef, + moveFocus, + syncFocusIdx, + } = useConflictFocus(fileStates.length) - useEffect(() => { - if (open) { - setFocusIdx(0) // eslint-disable-line react-hooks/set-state-in-effect -- reset on dialog open - focusIdxRef.current = 0 - } - }, [open]) - - const handleKeyDown = useCallback((e: React.KeyboardEvent) => { + const handleKeyDown = useCallback((e: KeyboardEvent) => { if (e.key === 'Escape') { onClose() return } - const idx = focusIdxRef.current - const file = fileStates[idx] + if (handleNavigationKey(e, moveFocus)) return - if (e.key === 'ArrowDown' || (e.key === 'Tab' && !e.shiftKey)) { - e.preventDefault() - const next = Math.min(idx + 1, fileStates.length - 1) - setFocusIdx(next) - focusIdxRef.current = next - return - } - if (e.key === 'ArrowUp' || (e.key === 'Tab' && e.shiftKey)) { - e.preventDefault() - const prev = Math.max(idx - 1, 0) - setFocusIdx(prev) - focusIdxRef.current = prev - return - } - - if ((e.key === 'k' || e.key === 'K') && file && !file.resolving && !e.metaKey && !e.ctrlKey) { - e.preventDefault() - onResolveFile(file.file, 'ours') - } else if ((e.key === 't' || e.key === 'T') && file && !file.resolving && !e.metaKey && !e.ctrlKey) { - e.preventDefault() - onResolveFile(file.file, 'theirs') - } else if ((e.key === 'o' || e.key === 'O') && file && !isBinaryFile(file.file) && !e.metaKey && !e.ctrlKey) { - e.preventDefault() - onOpenInEditor(file.file) - } else if (e.key === 'Enter' && allResolved && !committing) { - e.preventDefault() - onCommit() - } - }, [fileStates, allResolved, committing, onResolveFile, onOpenInEditor, onCommit, onClose]) + const focusedIndex = clampFocusIndex(focusIdxRef.current, fileStates.length) + const file = fileStates[focusedIndex] + if (handleResolutionShortcut(e, file, onResolveFile)) return + if (handleOpenShortcut(e, file, onOpenInEditor)) return + handleCommitShortcut({ allResolved, committing, event: e, onCommit }) + }, [allResolved, committing, fileStates, focusIdxRef, moveFocus, onClose, onCommit, onOpenInEditor, onResolveFile]) return ( - { if (!isOpen) onClose() }}> - - -
- - Resolve Merge Conflicts -
- - {fileStates.length} file{fileStates.length !== 1 ? 's have' : ' has'} merge conflicts. Choose how to resolve each file. - -
+ + -
- {fileStates.map((state, i) => ( - onResolveFile(state.file, strategy)} - onOpenInEditor={() => onOpenInEditor(state.file)} - onFocus={() => { - setFocusIdx(i) - focusIdxRef.current = i - }} - /> - ))} -
+ - {error && ( -

{error}

- )} + {error && ( +

{error}

+ )} - - - K = keep mine · T = keep theirs · O = open · Enter = commit - -
- - -
-
-
-
+ + ) } diff --git a/src/components/CreateNoteDialog.tsx b/src/components/CreateNoteDialog.tsx index 6e23c468..99ba6be2 100644 --- a/src/components/CreateNoteDialog.tsx +++ b/src/components/CreateNoteDialog.tsx @@ -97,7 +97,7 @@ export function CreateNoteDialog({ open, onClose, onCreate, defaultType, customT className={cn( "rounded-full text-xs", type === t - ? "bg-[var(--accent-blue)] text-white" + ? "bg-[var(--accent-blue)] text-primary-foreground" : "border-[var(--accent-blue)] text-[var(--accent-blue)]" )} onClick={() => setType(t)} diff --git a/src/components/DiffView.test.tsx b/src/components/DiffView.test.tsx index 6139e38e..dbec9f17 100644 --- a/src/components/DiffView.test.tsx +++ b/src/components/DiffView.test.tsx @@ -19,16 +19,18 @@ describe('DiffView', () => { it('applies green styling to added lines', () => { const diff = '+added line' - const { container } = render() - const addedLine = container.querySelector('.text-\\[\\#4caf50\\]') + render() + const addedLine = screen.getByText('+added line').closest('div') expect(addedLine).toBeInTheDocument() + expect(addedLine).toHaveClass('text-[var(--diff-added-text)]') }) it('applies red styling to removed lines', () => { const diff = '-removed line' - const { container } = render() - const removedLine = container.querySelector('.text-\\[\\#f44336\\]') + render() + const removedLine = screen.getByText('-removed line').closest('div') expect(removedLine).toBeInTheDocument() + expect(removedLine).toHaveClass('text-[var(--diff-removed-text)]') }) it('applies hunk header styling to @@ lines', () => { diff --git a/src/components/DiffView.tsx b/src/components/DiffView.tsx index 9f61bcdf..012d5428 100644 --- a/src/components/DiffView.tsx +++ b/src/components/DiffView.tsx @@ -7,9 +7,9 @@ interface DiffViewProps { const DIFF_HEADER_PREFIXES = ['diff', 'index', '---', '+++', 'new file'] function classifyDiffLine(line: string): string { - if (line.startsWith('+') && !line.startsWith('+++')) return 'bg-[rgba(76,175,80,0.12)] text-[#4caf50]' - if (line.startsWith('-') && !line.startsWith('---')) return 'bg-[rgba(244,67,54,0.12)] text-[#f44336]' - if (line.startsWith('@@')) return 'bg-[rgba(33,150,243,0.08)] text-primary italic' + if (line.startsWith('+') && !line.startsWith('+++')) return 'bg-[var(--diff-added-bg)] text-[var(--diff-added-text)]' + if (line.startsWith('-') && !line.startsWith('---')) return 'bg-[var(--diff-removed-bg)] text-[var(--diff-removed-text)]' + if (line.startsWith('@@')) return 'bg-[var(--diff-hunk-bg)] text-primary italic' if (DIFF_HEADER_PREFIXES.some((p) => line.startsWith(p))) return 'bg-muted text-muted-foreground font-semibold' return 'text-secondary-foreground' } diff --git a/src/components/Editor.css b/src/components/Editor.css index ad4482ba..f5f9d30d 100644 --- a/src/components/Editor.css +++ b/src/components/Editor.css @@ -68,7 +68,7 @@ /* Drag-over state: subtle border highlight */ .editor__blocknote-container--drag-over { - outline: 2px dashed var(--primary, #155DFF); + outline: 2px dashed var(--border-focus); outline-offset: -2px; } @@ -80,18 +80,18 @@ display: flex; align-items: center; justify-content: center; - background: rgba(21, 93, 255, 0.06); + background: var(--state-drag-target); pointer-events: none; } .editor__drop-overlay-label { padding: 10px 20px; border-radius: 8px; - background: var(--background, #fff); - color: var(--primary, #155DFF); + background: var(--surface-popover); + color: var(--primary); font-size: 14px; font-weight: 500; - box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); + box-shadow: 0 2px 8px var(--shadow-dialog); } .editor__blocknote-container .bn-container { @@ -246,7 +246,7 @@ display: flex; align-items: center; gap: 4px; - color: var(--text-faint, #999); + color: var(--text-faint); font-size: 13px; opacity: 0; transition: opacity 0.15s; @@ -274,9 +274,9 @@ flex-direction: column; min-width: 140px; border-radius: 8px; - border: 1px solid var(--border-dialog, var(--border)); + border: 1px solid var(--border-dialog); background: var(--popover); - box-shadow: 0 4px 16px var(--shadow-dialog, rgba(0,0,0,0.1)); + box-shadow: 0 4px 16px var(--shadow-dialog); padding: 4px; } @@ -297,7 +297,7 @@ } .note-icon-menu__item--danger { - color: var(--destructive, #ef4444); + color: var(--destructive); } /* ============================================= diff --git a/src/components/EditorTheme.css b/src/components/EditorTheme.css index a7f036df..deae6b04 100644 --- a/src/components/EditorTheme.css +++ b/src/components/EditorTheme.css @@ -67,7 +67,7 @@ margin-top: var(--headings-h1-margin-top) !important; margin-bottom: var(--headings-h1-margin-bottom) !important; padding-bottom: 16px !important; - border-bottom: 1px solid var(--border-primary, rgba(0,0,0,0.1)); + border-bottom: 1px solid var(--border-primary); } .editor__blocknote-container [data-content-type="heading"]:not([data-level]) .bn-inline-content, .editor__blocknote-container [data-content-type="heading"][data-level="1"] .bn-inline-content { diff --git a/src/components/FeedbackDialog.tsx b/src/components/FeedbackDialog.tsx index ea37744d..99e34ef9 100644 --- a/src/components/FeedbackDialog.tsx +++ b/src/components/FeedbackDialog.tsx @@ -133,10 +133,17 @@ function LinkFallbackBanner({ linkFallback }: { linkFallback: LinkFallback | nul if (!linkFallback) return null return ( -
+

Couldn’t open {linkFallback.label} automatically.

-

Open this URL manually instead:

-

+

Open this URL manually instead:

+

{linkFallback.url}

@@ -171,7 +178,7 @@ function BugReportActions({

Diagnostics copied.

) : null} {copyState === 'failed' ? ( -

+

Clipboard access is unavailable right now. You can still open GitHub Issues directly.

) : null} diff --git a/src/components/LinuxTitlebar.tsx b/src/components/LinuxTitlebar.tsx index fedf1111..c5ad803e 100644 --- a/src/components/LinuxTitlebar.tsx +++ b/src/components/LinuxTitlebar.tsx @@ -163,7 +163,7 @@ function TitlebarButton({ aria-label={ariaLabel} className={[ 'h-full w-[46px] rounded-none text-foreground/70 hover:text-foreground', - close ? 'hover:bg-red-500 hover:text-white' : 'hover:bg-foreground/10', + close ? 'hover:bg-destructive hover:text-destructive-foreground' : 'hover:bg-foreground/10', ].join(' ')} onClick={onClick} data-no-drag diff --git a/src/components/PulseView.tsx b/src/components/PulseView.tsx index a0cfd984..4aedbacb 100644 --- a/src/components/PulseView.tsx +++ b/src/components/PulseView.tsx @@ -60,9 +60,9 @@ const STATUS_ICON = { } as const const STATUS_COLOR = { - added: 'var(--accent-green, #16a34a)', - modified: 'var(--accent-orange, #ea580c)', - deleted: 'var(--destructive, #dc2626)', + added: 'var(--accent-green)', + modified: 'var(--accent-orange)', + deleted: 'var(--destructive)', } as const const PULSE_ROW_FOCUS_CLASS_NAME = 'focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring/70 focus-visible:ring-inset' diff --git a/src/components/RawEditorView.tsx b/src/components/RawEditorView.tsx index 1f702ba5..231b571b 100644 --- a/src/components/RawEditorView.tsx +++ b/src/components/RawEditorView.tsx @@ -31,29 +31,119 @@ export interface RawEditorViewProps { const DEBOUNCE_MS = 500 const DROPDOWN_MAX_HEIGHT = 200 -export function RawEditorView({ content, path, entries, onContentChange, onSave, latestContentRef, vaultPath }: RawEditorViewProps) { - const containerRef = useRef(null) - const debounceRef = useRef | null>(null) - const pathRef = useRef(path) - const onContentChangeRef = useRef(onContentChange) - const onSaveRef = useRef(onSave) - const latestDocRef = useRef(content) - useEffect(() => { pathRef.current = path }, [path]) - // Expose latest doc content to parent via ref - useEffect(() => { if (latestContentRef) latestContentRef.current = content }, [latestContentRef, content]) - useEffect(() => { onContentChangeRef.current = onContentChange }, [onContentChange]) - useEffect(() => { onSaveRef.current = onSave }, [onSave]) +type PendingChangeRefs = { + debounceRef: React.MutableRefObject | null> + latestDocRef: React.MutableRefObject + onContentChangeRef: React.MutableRefObject + pathRef: React.MutableRefObject +} - const [autocomplete, setAutocomplete] = useState(null) +function useLatestRef(value: T): React.MutableRefObject { + const ref = useRef(value) + useEffect(() => { ref.current = value }, [value]) + return ref +} + +function flushPendingRawEditorChange({ + debounceRef, + latestDocRef, + onContentChangeRef, + pathRef, +}: PendingChangeRefs): void { + if (!debounceRef.current) return + + clearTimeout(debounceRef.current) + debounceRef.current = null + onContentChangeRef.current(pathRef.current, latestDocRef.current) +} + +function moveRawEditorAutocompleteSelection( + autocomplete: RawEditorAutocompleteState, + direction: 'next' | 'previous', +): RawEditorAutocompleteState { + const selectedIndex = direction === 'next' + ? Math.min(autocomplete.selectedIndex + 1, autocomplete.items.length - 1) + : Math.max(autocomplete.selectedIndex - 1, 0) + + return { ...autocomplete, selectedIndex } +} + +function RawEditorYamlErrorBanner({ error }: { error: string | null }) { + if (!error) return null + + return ( +
+ YAML error: + {error} +
+ ) +} + +function RawEditorAutocompleteDropdown({ + autocomplete, + onItemHover, + position, +}: { + autocomplete: RawEditorAutocompleteState | null + onItemHover: (index: number) => void + position: { top: number; left: number } +}) { + if (!autocomplete || autocomplete.items.length === 0) return null + + return ( +
+ `${item.title}-${item.path ?? i}`} + onItemClick={(item) => item.onItemClick()} + onItemHover={onItemHover} + /> +
+ ) +} + +type RawEditorPendingChanges = PendingChangeRefs & { + handleDocChange: (doc: string) => void + handleSave: () => void + yamlError: string | null +} + +function useRawEditorPendingChanges({ + content, + latestContentRef, + onContentChange, + onSave, + path, +}: Pick): RawEditorPendingChanges { + const debounceRef = useRef | null>(null) + const pathRef = useLatestRef(path) + const onContentChangeRef = useLatestRef(onContentChange) + const onSaveRef = useLatestRef(onSave) + const latestContentRefStable = useRef(latestContentRef) + const latestDocRef = useRef(content) const [yamlError, setYamlError] = useState(() => detectYamlError(content)) - const typeEntryMap = useMemo(() => buildTypeEntryMap(entries), [entries]) - - const baseItems = useMemo(() => buildRawEditorBaseItems(entries), [entries]) - - const insertWikilinkRef = useRef<(target: string) => void>(() => {}) - - const latestContentRefStable = useRef(latestContentRef) + useEffect(() => { if (latestContentRef) latestContentRef.current = content }, [latestContentRef, content]) useEffect(() => { latestContentRefStable.current = latestContentRef }, [latestContentRef]) const handleDocChange = useCallback((doc: string) => { @@ -64,48 +154,149 @@ export function RawEditorView({ content, path, entries, onContentChange, onSave, debounceRef.current = setTimeout(() => { onContentChangeRef.current(pathRef.current, doc) }, DEBOUNCE_MS) - }, []) - - const handleCursorActivity = useCallback((view: EditorView) => { - const doc = view.state.doc.toString() - const cursor = view.state.selection.main.head - const query = extractWikilinkQuery(doc, cursor) - if (query === null || query.length < MIN_QUERY_LENGTH) { - setAutocomplete(null) - return - } - const nextAutocomplete = buildRawEditorAutocompleteState({ - view, - baseItems, - query, - typeEntryMap, - onInsertTarget: (target: string) => insertWikilinkRef.current(target), - vaultPath: vaultPath ?? '', - }) - setAutocomplete(nextAutocomplete) - }, [baseItems, typeEntryMap, vaultPath]) + }, [latestContentRefStable, onContentChangeRef, pathRef]) const handleSave = useCallback(() => { - if (debounceRef.current) { - clearTimeout(debounceRef.current) - debounceRef.current = null - onContentChangeRef.current(pathRef.current, latestDocRef.current) - } + flushPendingRawEditorChange({ debounceRef, latestDocRef, onContentChangeRef, pathRef }) onSaveRef.current() - }, []) + }, [onContentChangeRef, onSaveRef, pathRef]) - const handleEscape = useCallback(() => { + useEffect(() => { + return () => { + flushPendingRawEditorChange({ debounceRef, latestDocRef, onContentChangeRef, pathRef }) + } + }, [onContentChangeRef, pathRef]) + + return { + debounceRef, + handleDocChange, + handleSave, + latestDocRef, + onContentChangeRef, + pathRef, + yamlError, + } +} + +type RawEditorAutocompleteDirection = 'next' | 'previous' +type RawEditorSetAutocomplete = React.Dispatch> +type RawEditorTypeEntryMap = ReturnType + +function getRawEditorAutocompleteDirection(key: string): RawEditorAutocompleteDirection | null { + if (key === 'ArrowDown') return 'next' + if (key === 'ArrowUp') return 'previous' + return null +} + +function buildNextRawEditorAutocomplete({ + baseItems, + insertWikilinkRef, + typeEntryMap, + vaultPath, + view, +}: { + baseItems: ReturnType + insertWikilinkRef: React.MutableRefObject<(target: string) => void> + typeEntryMap: RawEditorTypeEntryMap + vaultPath?: string + view: EditorView +}): RawEditorAutocompleteState | null { + const doc = view.state.doc.toString() + const cursor = view.state.selection.main.head + const query = extractWikilinkQuery(doc, cursor) + if (query === null || query.length < MIN_QUERY_LENGTH) return null + + return buildRawEditorAutocompleteState({ + view, + baseItems, + query, + typeEntryMap, + onInsertTarget: (target: string) => insertWikilinkRef.current(target), + vaultPath: vaultPath ?? '', + }) +} + +function useRawEditorAutocompleteEscape( + autocomplete: RawEditorAutocompleteState | null, + setAutocomplete: RawEditorSetAutocomplete, +) { + return useCallback(() => { if (autocomplete) { setAutocomplete(null); return true } return false - }, [autocomplete]) + }, [autocomplete, setAutocomplete]) +} - const viewRef = useCodeMirror(containerRef, content, { - onDocChange: handleDocChange, - onCursorActivity: handleCursorActivity, - onSave: handleSave, - onEscape: handleEscape, - }) +function useRawEditorAutocompleteKeyboard( + autocomplete: RawEditorAutocompleteState | null, + setAutocomplete: RawEditorSetAutocomplete, +) { + return useCallback((e: React.KeyboardEvent) => { + if (!autocomplete) return + if (e.key === 'Enter') { + e.preventDefault() + autocomplete.items[autocomplete.selectedIndex]?.onItemClick() + return + } + + const direction = getRawEditorAutocompleteDirection(e.key) + if (!direction) return + + e.preventDefault() + setAutocomplete(prev => prev ? moveRawEditorAutocompleteSelection(prev, direction) : null) + }, [autocomplete, setAutocomplete]) +} + +function useRawEditorAutocompleteController({ + entries, + vaultPath, +}: Pick) { + const [autocomplete, setAutocomplete] = useState(null) + const typeEntryMap = useMemo(() => buildTypeEntryMap(entries), [entries]) + const baseItems = useMemo(() => buildRawEditorBaseItems(entries), [entries]) + const insertWikilinkRef = useRef<(target: string) => void>(() => {}) + + const handleCursorActivity = useCallback((view: EditorView) => { + setAutocomplete(buildNextRawEditorAutocomplete({ + baseItems, + insertWikilinkRef, + typeEntryMap, + vaultPath, + view, + })) + }, [baseItems, typeEntryMap, vaultPath]) + + const handleItemHover = useCallback((index: number) => { + setAutocomplete(prev => prev ? { ...prev, selectedIndex: index } : null) + }, []) + + const handleEscape = useRawEditorAutocompleteEscape(autocomplete, setAutocomplete) + const handleAutocompleteKey = useRawEditorAutocompleteKeyboard(autocomplete, setAutocomplete) + + return { + autocomplete, + handleAutocompleteKey, + handleCursorActivity, + handleEscape, + handleItemHover, + insertWikilinkRef, + setAutocomplete, + } +} + +function useRawEditorWikilinkInsertion({ + debounceRef, + insertWikilinkRef, + latestDocRef, + onContentChangeRef, + pathRef, + setAutocomplete, + viewRef, +}: PendingChangeRefs & { + insertWikilinkRef: React.MutableRefObject<(target: string) => void> + setAutocomplete: RawEditorSetAutocomplete + viewRef: React.MutableRefObject +}) { const insertWikilink = useCallback((target: string) => { const view = viewRef.current if (!view) return @@ -127,81 +318,48 @@ export function RawEditorView({ content, path, entries, onContentChange, onSave, onContentChangeRef.current(pathRef.current, replacement.text) view.focus() - }, [viewRef]) + }, [debounceRef, latestDocRef, onContentChangeRef, pathRef, setAutocomplete, viewRef]) - useEffect(() => { insertWikilinkRef.current = insertWikilink }, [insertWikilink]) + useEffect(() => { insertWikilinkRef.current = insertWikilink }, [insertWikilinkRef, insertWikilink]) +} - const handleAutocompleteKey = useCallback((e: React.KeyboardEvent) => { - if (!autocomplete) return - if (e.key === 'ArrowDown') { - e.preventDefault() - setAutocomplete(prev => prev - ? { ...prev, selectedIndex: Math.min(prev.selectedIndex + 1, prev.items.length - 1) } - : null) - } else if (e.key === 'ArrowUp') { - e.preventDefault() - setAutocomplete(prev => prev - ? { ...prev, selectedIndex: Math.max(prev.selectedIndex - 1, 0) } - : null) - } else if (e.key === 'Enter') { - e.preventDefault() - const item = autocomplete.items[autocomplete.selectedIndex] - if (item) item.onItemClick() - } - }, [autocomplete]) +export function RawEditorView({ content, path, entries, onContentChange, onSave, latestContentRef, vaultPath }: RawEditorViewProps) { + const containerRef = useRef(null) + const pendingChanges = useRawEditorPendingChanges({ content, latestContentRef, onContentChange, onSave, path }) + const autocompleteController = useRawEditorAutocompleteController({ entries, vaultPath }) + const viewRef = useCodeMirror(containerRef, content, { + onDocChange: pendingChanges.handleDocChange, + onCursorActivity: autocompleteController.handleCursorActivity, + onSave: pendingChanges.handleSave, + onEscape: autocompleteController.handleEscape, + }) - // Flush pending debounce on unmount - useEffect(() => { - return () => { - if (debounceRef.current) { - clearTimeout(debounceRef.current) - onContentChangeRef.current(pathRef.current, latestDocRef.current) - } - } - }, []) + useRawEditorWikilinkInsertion({ + debounceRef: pendingChanges.debounceRef, + insertWikilinkRef: autocompleteController.insertWikilinkRef, + latestDocRef: pendingChanges.latestDocRef, + onContentChangeRef: pendingChanges.onContentChangeRef, + pathRef: pendingChanges.pathRef, + setAutocomplete: autocompleteController.setAutocomplete, + viewRef, + }) - const dropdownPosition = getRawEditorDropdownPosition(autocomplete, DROPDOWN_MAX_HEIGHT, window) + const dropdownPosition = getRawEditorDropdownPosition(autocompleteController.autocomplete, DROPDOWN_MAX_HEIGHT, window) return ( -
- {yamlError && ( -
- YAML error: - {yamlError} -
- )} +
+
- {autocomplete && autocomplete.items.length > 0 && ( -
- `${item.title}-${item.path ?? i}`} - onItemClick={(item) => item.onItemClick()} - onItemHover={(i) => setAutocomplete(prev => prev ? { ...prev, selectedIndex: i } : null)} - /> -
- )} +
) } diff --git a/src/components/SettingsPanel.test.tsx b/src/components/SettingsPanel.test.tsx index 2634a941..9b1494c8 100644 --- a/src/components/SettingsPanel.test.tsx +++ b/src/components/SettingsPanel.test.tsx @@ -2,6 +2,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import { fireEvent, render, screen, within } from '@testing-library/react' import { SettingsPanel } from './SettingsPanel' import type { Settings } from '../types' +import { THEME_MODE_STORAGE_KEY } from '../lib/themeMode' const emptySettings: Settings = { auto_pull_interval_minutes: null, @@ -14,6 +15,7 @@ const emptySettings: Settings = { analytics_enabled: null, anonymous_id: null, release_channel: null, + theme_mode: null, } function installPointerCapturePolyfill() { @@ -28,12 +30,27 @@ function installPointerCapturePolyfill() { } } +function createStorageMock(): Storage { + const values = new Map() + return { + get length() { return values.size }, + clear: vi.fn(() => { values.clear() }), + getItem: vi.fn((key: string) => values.get(key) ?? null), + key: vi.fn((index: number) => Array.from(values.keys())[index] ?? null), + removeItem: vi.fn((key: string) => { values.delete(key) }), + setItem: vi.fn((key: string, value: string) => { values.set(key, value) }), + } +} + describe('SettingsPanel', () => { const onSave = vi.fn() const onClose = vi.fn() + const localStorageMock = createStorageMock() beforeEach(() => { vi.clearAllMocks() + Object.defineProperty(window, 'localStorage', { value: localStorageMock, configurable: true }) + window.localStorage.clear() installPointerCapturePolyfill() }) @@ -65,10 +82,62 @@ describe('SettingsPanel', () => { autogit_idle_threshold_seconds: 90, autogit_inactive_threshold_seconds: 30, release_channel: null, + theme_mode: 'light', })) expect(onClose).toHaveBeenCalled() }) + it('defaults the color mode control to light', () => { + render( + + ) + + expect(screen.getByTestId('settings-theme-mode')).toBeInTheDocument() + expect(screen.getByRole('radio', { name: 'Light' })).toHaveAttribute('aria-checked', 'true') + expect(screen.getByRole('radio', { name: 'Dark' })).toHaveAttribute('aria-checked', 'false') + }) + + it('uses the stored color mode mirror when settings have no saved mode', () => { + window.localStorage.setItem(THEME_MODE_STORAGE_KEY, 'dark') + + render( + + ) + + expect(screen.getByRole('radio', { name: 'Dark' })).toHaveAttribute('aria-checked', 'true') + }) + + it('saves the selected dark color mode', () => { + render( + + ) + + fireEvent.click(screen.getByRole('radio', { name: 'Dark' })) + fireEvent.click(screen.getByTestId('settings-save')) + + expect(onSave).toHaveBeenCalledWith(expect.objectContaining({ + theme_mode: 'dark', + })) + }) + + it('preserves a saved dark color mode until changed', () => { + render( + + ) + + expect(screen.getByRole('radio', { name: 'Dark' })).toHaveAttribute('aria-checked', 'true') + fireEvent.click(screen.getByTestId('settings-save')) + + expect(onSave).toHaveBeenCalledWith(expect.objectContaining({ + theme_mode: 'dark', + })) + }) + it('defaults the release channel trigger to stable', () => { render( diff --git a/src/components/SettingsPanel.tsx b/src/components/SettingsPanel.tsx index db6ed401..82f9ebb3 100644 --- a/src/components/SettingsPanel.tsx +++ b/src/components/SettingsPanel.tsx @@ -15,8 +15,13 @@ import { type MouseEvent as ReactMouseEvent, type ReactNode, } from 'react' -import { X } from '@phosphor-icons/react' +import { Moon, Sun, X } from '@phosphor-icons/react' import type { Settings } from '../types' +import { + DEFAULT_THEME_MODE, + readStoredThemeMode, + type ThemeMode, +} from '../lib/themeMode' import { normalizeReleaseChannel, serializeReleaseChannel, type ReleaseChannel } from '../lib/releaseChannel' import { trackEvent } from '../lib/telemetry' import { Button } from './ui/button' @@ -50,6 +55,7 @@ interface SettingsDraft { autoAdvanceInboxAfterOrganize: boolean defaultAiAgent: AiAgentId releaseChannel: ReleaseChannel + themeMode: ThemeMode initialH1AutoRename: boolean crashReporting: boolean analytics: boolean @@ -73,6 +79,8 @@ interface SettingsBodyProps { setDefaultAiAgent: (value: AiAgentId) => void releaseChannel: ReleaseChannel setReleaseChannel: (value: ReleaseChannel) => void + themeMode: ThemeMode + setThemeMode: (value: ThemeMode) => void initialH1AutoRename: boolean setInitialH1AutoRename: (value: boolean) => void explicitOrganization: boolean @@ -109,6 +117,7 @@ function createSettingsDraft( autoAdvanceInboxAfterOrganize: settings.auto_advance_inbox_after_organize ?? false, defaultAiAgent: resolveDefaultAiAgent(settings.default_ai_agent), releaseChannel: normalizeReleaseChannel(settings.release_channel), + themeMode: resolveSettingsDraftThemeMode(settings.theme_mode), initialH1AutoRename: settings.initial_h1_auto_rename_enabled ?? true, crashReporting: settings.crash_reporting_enabled ?? false, analytics: settings.analytics_enabled ?? false, @@ -116,6 +125,12 @@ function createSettingsDraft( } } +function resolveSettingsDraftThemeMode(themeMode: Settings['theme_mode']): ThemeMode { + if (themeMode) return themeMode + if (typeof window === 'undefined') return DEFAULT_THEME_MODE + return readStoredThemeMode(window.localStorage) ?? DEFAULT_THEME_MODE +} + function resolveTelemetryConsent(settings: Settings, draft: SettingsDraft): boolean | null { if (draft.crashReporting || draft.analytics) return true return settings.telemetry_consent === null ? null : false @@ -141,6 +156,7 @@ function buildSettingsFromDraft(settings: Settings, draft: SettingsDraft): Setti analytics_enabled: draft.analytics, anonymous_id: resolveAnonymousId(settings, draft), release_channel: serializeReleaseChannel(draft.releaseChannel), + theme_mode: draft.themeMode, initial_h1_auto_rename_enabled: draft.initialH1AutoRename, default_ai_agent: draft.defaultAiAgent, } @@ -251,14 +267,14 @@ function SettingsPanelInner({ return (
@@ -279,6 +295,8 @@ function SettingsPanelInner({ setDefaultAiAgent={(value) => updateDraft('defaultAiAgent', value)} releaseChannel={draft.releaseChannel} setReleaseChannel={(value) => updateDraft('releaseChannel', value)} + themeMode={draft.themeMode} + setThemeMode={(value) => updateDraft('themeMode', value)} initialH1AutoRename={draft.initialH1AutoRename} setInitialH1AutoRename={(value) => updateDraft('initialH1AutoRename', value)} explicitOrganization={draft.explicitOrganization} @@ -331,6 +349,8 @@ function SettingsBody({ setDefaultAiAgent, releaseChannel, setReleaseChannel, + themeMode, + setThemeMode, initialH1AutoRename, setInitialH1AutoRename, explicitOrganization, @@ -363,6 +383,13 @@ function SettingsBody({ /> + + + + ) { + return ( + <> + + + + + ) +} + +function ThemeModeControl({ + value, + onChange, +}: { + value: ThemeMode + onChange: (value: ThemeMode) => void +}) { + return ( +
+ + + + + + +
+ ) +} + +function ThemeModeButton({ + children, + label, + selected, + value, + onSelect, +}: { + children: ReactNode + label: string + selected: boolean + value: ThemeMode + onSelect: (value: ThemeMode) => void +}) { + return ( + + ) +} + function autoGitSectionDescription(isGitVault: boolean): string { return isGitVault ? 'Automatically create conservative Git checkpoints after editing pauses or when the app is no longer active.' diff --git a/src/components/SidebarParts.tsx b/src/components/SidebarParts.tsx index 38fe2fc0..296c7313 100644 --- a/src/components/SidebarParts.tsx +++ b/src/components/SidebarParts.tsx @@ -4,6 +4,7 @@ import { cn } from '@/lib/utils' import { getTypeColor, getTypeLightColor } from '../utils/typeColors' import { type IconProps } from '@phosphor-icons/react' import { SIDEBAR_ITEM_PADDING } from './sidebar/sidebarStyles' +import { Button } from './ui/button' const SIDEBAR_COUNT_PILL_STYLE = { borderRadius: 9999, @@ -359,6 +360,19 @@ function getSectionContextMenuHandler( return onContextMenu } +function resolveInlineRenameHandlers({ + isRenaming, + onRenameCancel, + onRenameSubmit, +}: { + isRenaming?: boolean + onRenameCancel?: () => void + onRenameSubmit?: (value: string) => void +}): { onRenameCancel: () => void; onRenameSubmit: (value: string) => void } | null { + if (!isRenaming || !onRenameSubmit || !onRenameCancel) return null + return { onRenameCancel, onRenameSubmit } +} + function SectionHeaderLabel({ type, label, @@ -378,13 +392,19 @@ function SectionHeaderLabel({ onRenameSubmit?: (value: string) => void onRenameCancel?: () => void }) { - if (isRenaming && onRenameSubmit && onRenameCancel) { + const inlineRenameHandlers = resolveInlineRenameHandlers({ + isRenaming, + onRenameCancel, + onRenameSubmit, + }) + + if (inlineRenameHandlers) { return ( ) } @@ -406,7 +426,7 @@ function SectionHeaderCountPill({ ) } @@ -458,9 +478,11 @@ function VisibilityPopoverItem({ const { sectionColor } = resolveSectionColors(type, customColor) return ( - + ) } @@ -482,7 +504,7 @@ export function VisibilityPopover({ sections, isSectionVisible, onToggle }: { return (
Show in sidebar
{sections.map((group) => ( @@ -500,7 +522,7 @@ export function VisibilityPopover({ sections, isSectionVisible, onToggle }: { function ToggleSwitch({ on }: { on: boolean }) { return (
-
+
) } diff --git a/src/components/SingleEditorView.test.tsx b/src/components/SingleEditorView.test.tsx index 4629d02b..1b3332a9 100644 --- a/src/components/SingleEditorView.test.tsx +++ b/src/components/SingleEditorView.test.tsx @@ -26,6 +26,7 @@ vi.mock('@blocknote/react', () => ({ slashMenu?: boolean sideMenu?: boolean onChange?: () => void + theme?: string }) => { const { children, @@ -205,6 +206,8 @@ describe('SingleEditorView', () => { state.capturedBlockNoteOnChange = null state.imageDropState.isDragOver = false state.wikilinkEntriesRef.current = [] + document.documentElement.removeAttribute('data-theme') + document.documentElement.classList.remove('dark') delete window.__laputaTest }) @@ -306,6 +309,22 @@ describe('SingleEditorView', () => { expect(onMentionItemClick).toHaveBeenCalledOnce() }) + it('passes the active document theme to BlockNote', () => { + document.documentElement.setAttribute('data-theme', 'dark') + document.documentElement.classList.add('dark') + + render( + , + ) + + expect(screen.getByTestId('blocknote-view')).toHaveAttribute('theme', 'dark') + expect(screen.getByTestId('blocknote-view')).toHaveAttribute('data-mantine-color-scheme', 'dark') + }) + it('defers rich-editor change propagation until IME composition ends', async () => { const editor = createEditor() const onChange = vi.fn() diff --git a/src/components/SingleEditorView.tsx b/src/components/SingleEditorView.tsx index 4ab92376..9bebc328 100644 --- a/src/components/SingleEditorView.tsx +++ b/src/components/SingleEditorView.tsx @@ -9,6 +9,7 @@ import { } from '@blocknote/react' import { components } from '@blocknote/mantine' import { MantineContext, MantineProvider } from '@mantine/core' +import { useDocumentThemeMode } from '../hooks/useDocumentThemeMode' import { useEditorTheme } from '../hooks/useTheme' import { useImageDrop } from '../hooks/useImageDrop' import { buildTypeEntryMap } from '../utils/typeColors' @@ -331,6 +332,7 @@ export function SingleEditorView({ editor, entries, onNavigateWikilink, onChange editable?: boolean }) { const { cssVars } = useEditorTheme() + const themeMode = useDocumentThemeMode() const containerRef = useRef(null) const handleContainerClick = useEditorContainerClickHandler({ editable, editor }) const handleEditorChange = useCompositionAwareEditorChange({ containerRef, onChange }) @@ -369,7 +371,7 @@ export function SingleEditorView({ editor, entries, onNavigateWikilink, onChange )} {currentKey === c.key && ( - {'\u2713'} + {'\u2713'} )} ))} @@ -162,6 +172,25 @@ interface KeyboardNavOptions { listRef: React.RefObject } +interface StatusSelectionOptions { + highlightIndex: number + allFiltered: string[] + showCreateOption: boolean + query: string +} + +function getStatusValueToSave({ + highlightIndex, + allFiltered, + showCreateOption, + query, +}: StatusSelectionOptions) { + const trimmed = query.trim() + if (highlightIndex >= 0 && highlightIndex < allFiltered.length) return allFiltered[highlightIndex] + if (showCreateOption && highlightIndex === allFiltered.length) return trimmed + return trimmed || null +} + function useStatusKeyboard(opts: KeyboardNavOptions) { const { allFiltered, totalOptions, showCreateOption, query, onSave, onCancel, listRef } = opts const [highlightIndex, setHighlightIndex] = useState(-1) @@ -173,29 +202,32 @@ function useStatusKeyboard(opts: KeyboardNavOptions) { items[index]?.scrollIntoView({ block: 'nearest' }) }, [listRef]) + const moveHighlight = useCallback((nextIndex: number) => { + setHighlightIndex(nextIndex) + scrollIntoView(nextIndex) + }, [scrollIntoView]) + + const submitHighlightedStatus = useCallback(() => { + const value = getStatusValueToSave({ highlightIndex, allFiltered, showCreateOption, query }) + if (value) onSave(value) + }, [highlightIndex, allFiltered, showCreateOption, query, onSave]) + const handleKeyDown = useCallback( (e: React.KeyboardEvent) => { switch (e.key) { case 'ArrowDown': { e.preventDefault() - const next = highlightIndex < totalOptions - 1 ? highlightIndex + 1 : 0 - setHighlightIndex(next) - scrollIntoView(next) + moveHighlight(getNextHighlightIndex(highlightIndex, totalOptions)) break } case 'ArrowUp': { e.preventDefault() - const prev = highlightIndex > 0 ? highlightIndex - 1 : totalOptions - 1 - setHighlightIndex(prev) - scrollIntoView(prev) + moveHighlight(getPreviousHighlightIndex(highlightIndex, totalOptions)) break } case 'Enter': { e.preventDefault() - const trimmed = query.trim() - if (highlightIndex >= 0 && highlightIndex < allFiltered.length) onSave(allFiltered[highlightIndex]) - else if (showCreateOption && highlightIndex === allFiltered.length) onSave(trimmed) - else if (trimmed) onSave(trimmed) + submitHighlightedStatus() break } case 'Escape': @@ -204,7 +236,7 @@ function useStatusKeyboard(opts: KeyboardNavOptions) { break } }, - [highlightIndex, totalOptions, allFiltered, showCreateOption, query, onSave, onCancel, scrollIntoView], + [highlightIndex, totalOptions, moveHighlight, submitHighlightedStatus, onCancel], ) const resetHighlight = useCallback(() => setHighlightIndex(-1), []) @@ -226,31 +258,15 @@ export function StatusDropdown({ const [colorEditingStatus, setColorEditingStatus] = useState(null) const inputRef = useRef(null) const listRef = useRef(null) - const anchorRef = useRef(null) + const anchorRef = useRef(null) const dropdownRef = useRef(null) - useLayoutEffect(() => { - const node = dropdownRef.current - if (!node) return - const anchor = anchorRef.current?.parentElement - if (!anchor) return - const rect = anchor.getBoundingClientRect() - const dropW = 208 - let left = rect.right - dropW - if (left < 8) left = 8 - if (left + dropW > window.innerWidth - 8) left = window.innerWidth - dropW - 8 - node.style.top = `${rect.bottom + 4}px` - node.style.left = `${left}px` - }, []) - - useEffect(() => { inputRef.current?.focus() }, []) + useAnchoredDropdownPosition({ anchorRef, dropdownRef, width: PROPERTY_DROPDOWN_WIDTH }) + useAutoFocus(inputRef) const { suggestedFiltered, vaultFiltered, allFiltered } = useStatusFiltering(query, vaultStatuses) - const showCreateOption = useMemo(() => { - if (!query.trim()) return false - return !allFiltered.some(s => s.toLowerCase() === query.trim().toLowerCase()) - }, [query, allFiltered]) + const showCreateOption = useMemo(() => isCreateOptionVisible(query, allFiltered), [query, allFiltered]) const totalOptions = allFiltered.length + (showCreateOption ? 1 : 0) diff --git a/src/components/TagsDropdown.tsx b/src/components/TagsDropdown.tsx index 7afeca72..f4441d13 100644 --- a/src/components/TagsDropdown.tsx +++ b/src/components/TagsDropdown.tsx @@ -1,7 +1,17 @@ -import { useState, useRef, useEffect, useLayoutEffect, useCallback, useMemo } from 'react' +import { useState, useRef, useCallback, useMemo } from 'react' import { createPortal } from 'react-dom' import { getTagStyle, setTagColor, getTagColorKey } from '../utils/tagStyles' import { ACCENT_COLORS } from '../utils/typeColors' +import { + getNextHighlightIndex, + getPreviousHighlightIndex, + isCreateOptionVisible, + useAnchoredDropdownPosition, + useAutoFocus, +} from './propertyDropdownUtils' + +const PROPERTY_DROPDOWN_WIDTH = 208 +const SELECTED_SWATCH_CHECK_STYLE = { color: 'var(--text-inverse)', fontSize: 8, lineHeight: 1 } as const export function TagPill({ tag, className }: { tag: string; className?: string }) { const style = getTagStyle(tag) @@ -40,7 +50,7 @@ function ColorPickerRow({ tag, onColorChange }: { tag: string; onColorChange: (t data-testid={`tag-color-option-${c.key}`} > {currentKey === c.key && ( - {'\u2713'} + {'\u2713'} )} ))} @@ -111,6 +121,28 @@ function useTagFiltering(query: string, vaultTags: string[]) { }, [query, vaultTags]) } +interface TagSelectionOptions { + highlightIndex: number + filtered: string[] + showCreateOption: boolean + query: string + selectedTags: Set +} + +function getTagValueToToggle({ + highlightIndex, + filtered, + showCreateOption, + query, + selectedTags, +}: TagSelectionOptions) { + const trimmed = query.trim() + if (highlightIndex >= 0 && highlightIndex < filtered.length) return filtered[highlightIndex] + if (showCreateOption && highlightIndex === filtered.length && trimmed) return trimmed + if (trimmed && !selectedTags.has(trimmed)) return trimmed + return null +} + function useTagKeyboard(opts: { filtered: string[]; totalOptions: number; showCreateOption: boolean query: string; selectedTags: Set @@ -127,33 +159,32 @@ function useTagKeyboard(opts: { items[index]?.scrollIntoView({ block: 'nearest' }) }, [listRef]) + const moveHighlight = useCallback((nextIndex: number) => { + setHighlightIndex(nextIndex) + scrollIntoView(nextIndex) + }, [scrollIntoView]) + + const submitHighlightedTag = useCallback(() => { + const value = getTagValueToToggle({ highlightIndex, filtered, showCreateOption, query, selectedTags }) + if (value) onToggle(value) + }, [highlightIndex, filtered, showCreateOption, query, selectedTags, onToggle]) + const handleKeyDown = useCallback( (e: React.KeyboardEvent) => { switch (e.key) { case 'ArrowDown': { e.preventDefault() - const next = highlightIndex < totalOptions - 1 ? highlightIndex + 1 : 0 - setHighlightIndex(next) - scrollIntoView(next) + moveHighlight(getNextHighlightIndex(highlightIndex, totalOptions)) break } case 'ArrowUp': { e.preventDefault() - const prev = highlightIndex > 0 ? highlightIndex - 1 : totalOptions - 1 - setHighlightIndex(prev) - scrollIntoView(prev) + moveHighlight(getPreviousHighlightIndex(highlightIndex, totalOptions)) break } case 'Enter': { e.preventDefault() - const trimmed = query.trim() - if (highlightIndex >= 0 && highlightIndex < filtered.length) { - onToggle(filtered[highlightIndex]) - } else if (showCreateOption && highlightIndex === filtered.length && trimmed) { - onToggle(trimmed) - } else if (trimmed && !selectedTags.has(trimmed)) { - onToggle(trimmed) - } + submitHighlightedTag() break } case 'Escape': @@ -162,7 +193,7 @@ function useTagKeyboard(opts: { break } }, - [highlightIndex, totalOptions, filtered, showCreateOption, query, selectedTags, onToggle, onClose, scrollIntoView], + [highlightIndex, totalOptions, moveHighlight, submitHighlightedTag, onClose], ) const resetHighlight = useCallback(() => setHighlightIndex(-1), []) @@ -180,33 +211,17 @@ export function TagsDropdown({ const [colorEditingTag, setColorEditingTag] = useState(null) const inputRef = useRef(null) const listRef = useRef(null) - const anchorRef = useRef(null) + const anchorRef = useRef(null) const dropdownRef = useRef(null) const selectedSet = useMemo(() => new Set(selectedTags), [selectedTags]) - useLayoutEffect(() => { - const node = dropdownRef.current - if (!node) return - const anchor = anchorRef.current?.parentElement - if (!anchor) return - const rect = anchor.getBoundingClientRect() - const dropW = 208 - let left = rect.right - dropW - if (left < 8) left = 8 - if (left + dropW > window.innerWidth - 8) left = window.innerWidth - dropW - 8 - node.style.top = `${rect.bottom + 4}px` - node.style.left = `${left}px` - }, []) - - useEffect(() => { inputRef.current?.focus() }, []) + useAnchoredDropdownPosition({ anchorRef, dropdownRef, width: PROPERTY_DROPDOWN_WIDTH }) + useAutoFocus(inputRef) const { filtered } = useTagFiltering(query, vaultTags) - const showCreateOption = useMemo(() => { - if (!query.trim()) return false - return !filtered.some(t => t.toLowerCase() === query.trim().toLowerCase()) - }, [query, filtered]) + const showCreateOption = useMemo(() => isCreateOptionVisible(query, filtered), [query, filtered]) const totalOptions = filtered.length + (showCreateOption ? 1 : 0) @@ -250,46 +265,25 @@ export function TagsDropdown({ />
- {filtered.length > 0 && ( -
- From vault - {filtered.map((tag, i) => ( - setHighlightIndex(i)} - colorEditing={colorEditingTag === tag} - onToggleColor={handleToggleColor} - onColorChange={handleColorChange} - /> - ))} -
- )} - {showCreateOption && ( - <> - {filtered.length > 0 &&
} - - - )} - {filtered.length === 0 && !showCreateOption && ( -
- No matching tags -
- )} + + 0} + highlighted={highlightIndex === filtered.length} + onToggle={onToggle} + onMouseEnter={() => setHighlightIndex(filtered.length)} + /> +
, @@ -298,3 +292,80 @@ export function TagsDropdown({ ) } + +interface VaultTagSectionProps { + tags: string[] + selectedTags: Set + highlightIndex: number + colorEditingTag: string | null + onToggle: (tag: string) => void + onHighlight: (index: number) => void + onToggleColor: (tag: string) => void + onColorChange: (tag: string, colorKey: string) => void +} + +function VaultTagSection({ + tags, + selectedTags, + highlightIndex, + colorEditingTag, + onToggle, + onHighlight, + onToggleColor, + onColorChange, +}: VaultTagSectionProps) { + if (tags.length === 0) return null + return ( +
+ From vault + {tags.map((tag, i) => ( + onHighlight(i)} + colorEditing={colorEditingTag === tag} + onToggleColor={onToggleColor} + onColorChange={onColorChange} + /> + ))} +
+ ) +} + +function CreateTagSection({ show, query, showDivider, highlighted, onToggle, onMouseEnter }: { + show: boolean; query: string; showDivider: boolean; highlighted: boolean + onToggle: (tag: string) => void; onMouseEnter: () => void +}) { + if (!show) return null + const trimmed = query.trim() + return ( + <> + {showDivider &&
} + + + ) +} + +function EmptyTagMessage({ show }: { show: boolean }) { + if (!show) return null + return ( +
+ No matching tags +
+ ) +} diff --git a/src/components/TelemetryConsentDialog.tsx b/src/components/TelemetryConsentDialog.tsx index 937266d6..ea7a33ba 100644 --- a/src/components/TelemetryConsentDialog.tsx +++ b/src/components/TelemetryConsentDialog.tsx @@ -1,5 +1,6 @@ import { ShieldCheck } from '@phosphor-icons/react' import { OnboardingShell } from './OnboardingShell' +import { Button } from './ui/button' interface TelemetryConsentDialogProps { onAccept: () => void @@ -10,8 +11,8 @@ export function TelemetryConsentDialog({ onAccept, onDecline }: TelemetryConsent return (
- - +

diff --git a/src/components/TypeSelector.tsx b/src/components/TypeSelector.tsx index f03d4d8b..71f3d711 100644 --- a/src/components/TypeSelector.tsx +++ b/src/components/TypeSelector.tsx @@ -132,7 +132,7 @@ function MissingTypeWarning({ variant="ghost" size="icon-xs" className={cn( - 'h-6 w-6 shrink-0 rounded-md border border-amber-300/80 bg-amber-50 p-0 text-amber-700 shadow-none hover:bg-amber-100 hover:text-amber-800', + 'h-6 w-6 shrink-0 rounded-md border border-[var(--feedback-warning-border)] bg-[var(--feedback-warning-bg)] p-0 text-[var(--feedback-warning-text)] shadow-none hover:brightness-95', !canCreateMissingType && 'cursor-default', )} data-testid="missing-type-warning" diff --git a/src/components/UpdateBanner.tsx b/src/components/UpdateBanner.tsx index 36d4e71d..bdb196c0 100644 --- a/src/components/UpdateBanner.tsx +++ b/src/components/UpdateBanner.tsx @@ -16,29 +16,29 @@ const bannerStyle = { alignItems: 'center', gap: 10, padding: '6px 12px', - background: '#1a56db', + background: 'var(--accent-blue)', borderBottom: 'none', fontSize: 13, - color: '#fff', + color: 'var(--text-inverse)', flexShrink: 0, } satisfies CSSProperties const iconStyle = { - color: '#fff', + color: 'var(--text-inverse)', flexShrink: 0, } satisfies CSSProperties const primaryActionStyle = { marginLeft: 'auto', padding: '3px 10px', - background: 'var(--primary)', - color: '#fff', + background: 'var(--text-inverse)', + color: 'var(--accent-blue)', fontSize: 12, fontWeight: 500, } satisfies CSSProperties const dismissButtonStyle = { - color: '#fff', + color: 'var(--text-inverse)', display: 'flex', padding: 2, } satisfies CSSProperties @@ -47,18 +47,18 @@ const progressTrackStyle = { flex: 1, maxWidth: 200, height: 4, - background: 'rgba(255,255,255,0.3)', + background: 'color-mix(in srgb, var(--text-inverse) 30%, transparent)', borderRadius: 2, overflow: 'hidden', } satisfies CSSProperties const progressTextStyle = { fontSize: 11, - color: 'rgba(255,255,255,0.85)', + color: 'color-mix(in srgb, var(--text-inverse) 85%, transparent)', } satisfies CSSProperties const readyIconStyle = { - color: 'var(--accent-green, #0F7B0F)', + color: 'var(--accent-green)', flexShrink: 0, } satisfies CSSProperties @@ -75,7 +75,7 @@ function renderAvailableContent(status: Extract Release Notes @@ -114,7 +114,7 @@ function renderDownloadingContent(status: Extract Restart Now diff --git a/src/components/WelcomeScreen.tsx b/src/components/WelcomeScreen.tsx index a7cddb67..452db171 100644 --- a/src/components/WelcomeScreen.tsx +++ b/src/components/WelcomeScreen.tsx @@ -175,7 +175,7 @@ const OPTION_DESC_STYLE: React.CSSProperties = { const ERROR_STYLE: React.CSSProperties = { fontSize: 13, - color: 'var(--destructive, #e03e3e)', + color: 'var(--destructive)', textAlign: 'center', margin: 0, } @@ -286,7 +286,7 @@ function getWelcomeScreenPresentation( } return { - heroBackground: 'var(--accent-yellow-light, #FFF3E0)', + heroBackground: 'var(--accent-yellow-light)', heroIcon: , openFolderLabel: 'Choose a different folder', subtitle: 'The vault folder could not be found on disk.\nIt may have been moved or deleted.', @@ -419,7 +419,7 @@ export function WelcomeScreen({

} - iconBg="var(--accent-purple-light, #F3E8FF)" + iconBg="var(--accent-purple-light)" label="Get started with a template" description={presentation.templateDescription} loadingLabel="Downloading template…" @@ -434,7 +434,7 @@ export function WelcomeScreen({ } - iconBg="var(--accent-blue-light, #EBF4FF)" + iconBg="var(--accent-blue-light)" label="Create empty vault" description="Start fresh in an empty folder with Tolaria defaults" loadingLabel="Creating vault…" @@ -448,7 +448,7 @@ export function WelcomeScreen({ } - iconBg="var(--accent-green-light, #E8F5E9)" + iconBg="var(--accent-green-light)" label={presentation.openFolderLabel} description="Point to a folder you already have" onClick={onOpenFolder} diff --git a/src/components/WikilinkSuggestionMenu.css b/src/components/WikilinkSuggestionMenu.css index a1505c73..3c26fb6f 100644 --- a/src/components/WikilinkSuggestionMenu.css +++ b/src/components/WikilinkSuggestionMenu.css @@ -1,9 +1,9 @@ /* Wikilink autocomplete — floating menu container (positioned by BlockNote) */ .wikilink-menu { - background: var(--bg-primary, #fff); - border: 1px solid var(--border, rgba(0, 0, 0, 0.1)); + background: var(--popover); + border: 1px solid var(--border); border-radius: 6px; - box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08); + box-shadow: 0 4px 12px var(--shadow-dialog); max-height: 320px; overflow: hidden; min-width: 260px; @@ -24,7 +24,7 @@ .wikilink-menu__item:hover, .wikilink-menu__item--selected { - background: hsl(var(--accent)); + background: var(--accent); } .wikilink-menu__title { diff --git a/src/components/folder-tree/FolderItemRow.tsx b/src/components/folder-tree/FolderItemRow.tsx index da6b99ba..d4008da8 100644 --- a/src/components/folder-tree/FolderItemRow.tsx +++ b/src/components/folder-tree/FolderItemRow.tsx @@ -52,7 +52,7 @@ export function FolderItemRow({ className={cn( 'group relative flex items-center gap-1 rounded transition-colors', isSelected - ? 'bg-[var(--accent-blue-light,rgba(0,100,255,0.08))] text-primary' + ? 'bg-[var(--accent-blue-light)] text-primary' : 'text-foreground hover:bg-accent', )} style={{ paddingLeft: depthIndent, borderRadius: 4 }} diff --git a/src/components/note-item/ChangeNoteContent.tsx b/src/components/note-item/ChangeNoteContent.tsx index cd6f359e..117db19d 100644 --- a/src/components/note-item/ChangeNoteContent.tsx +++ b/src/components/note-item/ChangeNoteContent.tsx @@ -11,11 +11,11 @@ type ChangeStatsEntry = VaultEntry & { } const CHANGE_STATUS_DISPLAY: Record = { - modified: { label: 'Modified', color: 'var(--accent-orange, #f59e0b)', symbol: '·' }, - added: { label: 'Added', color: 'var(--accent-green, #22c55e)', symbol: '+' }, - untracked: { label: 'Added', color: 'var(--accent-green, #22c55e)', symbol: '+' }, - deleted: { label: 'Deleted', color: 'var(--destructive, #ef4444)', symbol: '−' }, - renamed: { label: 'Renamed', color: 'var(--accent-orange, #f59e0b)', symbol: 'R' }, + modified: { label: 'Modified', color: 'var(--accent-orange)', symbol: '·' }, + added: { label: 'Added', color: 'var(--accent-green)', symbol: '+' }, + untracked: { label: 'Added', color: 'var(--accent-green)', symbol: '+' }, + deleted: { label: 'Deleted', color: 'var(--destructive)', symbol: '−' }, + renamed: { label: 'Renamed', color: 'var(--accent-orange)', symbol: 'R' }, } function readChangeStats(entry: VaultEntry): Required> { @@ -72,7 +72,7 @@ function buildChangeStatBadges( if (shouldShowAddedStat(status, addedLines, deletedLines)) { badges.push({ - className: 'text-[var(--accent-green,#22c55e)]', + className: 'text-[var(--accent-green)]', testId: 'change-stat-added', value: `+${addedLines ?? 0}`, }) @@ -80,7 +80,7 @@ function buildChangeStatBadges( if (shouldShowDeletedStat(status, addedLines, deletedLines)) { badges.push({ - className: 'text-[var(--destructive,#ef4444)]', + className: 'text-[var(--destructive)]', testId: 'change-stat-deleted', value: `-${deletedLines ?? 0}`, }) diff --git a/src/components/note-list/FilterPills.tsx b/src/components/note-list/FilterPills.tsx index 9d9be019..68adb0ac 100644 --- a/src/components/note-list/FilterPills.tsx +++ b/src/components/note-list/FilterPills.tsx @@ -13,7 +13,7 @@ const PILLS: { value: NoteListFilter; label: string }[] = [ { value: 'archived', label: 'Archived' }, ] -const BOTTOM_GRADIENT = 'linear-gradient(to bottom, transparent 0%, var(--card, #fff) 30%, var(--card, #fff) 100%)' +const BOTTOM_GRADIENT = 'linear-gradient(to bottom, transparent 0%, var(--card) 30%, var(--card) 100%)' function FilterPillsInner({ active, counts, onChange, position = 'top' }: FilterPillsProps) { const isBottom = position === 'bottom' diff --git a/src/components/note-list/InboxFilterPills.tsx b/src/components/note-list/InboxFilterPills.tsx index 8932cde3..25e7884c 100644 --- a/src/components/note-list/InboxFilterPills.tsx +++ b/src/components/note-list/InboxFilterPills.tsx @@ -14,7 +14,7 @@ const PILLS: { value: InboxPeriod; label: string }[] = [ { value: 'all', label: 'All' }, ] -const BOTTOM_GRADIENT = 'linear-gradient(to bottom, transparent 0%, var(--card, #fff) 30%, var(--card, #fff) 100%)' +const BOTTOM_GRADIENT = 'linear-gradient(to bottom, transparent 0%, var(--card) 30%, var(--card) 100%)' function InboxFilterPillsInner({ active, counts, onChange, position = 'top' }: InboxFilterPillsProps) { const isBottom = position === 'bottom' diff --git a/src/components/propertyDropdownUtils.test.ts b/src/components/propertyDropdownUtils.test.ts new file mode 100644 index 00000000..00195296 --- /dev/null +++ b/src/components/propertyDropdownUtils.test.ts @@ -0,0 +1,28 @@ +import { describe, expect, it } from 'vitest' +import { + getAnchoredDropdownLeft, + getNextHighlightIndex, + getPreviousHighlightIndex, + isCreateOptionVisible, +} from './propertyDropdownUtils' + +describe('propertyDropdownUtils', () => { + it('wraps highlight movement across the available option range', () => { + expect(getNextHighlightIndex(-1, 3)).toBe(0) + expect(getNextHighlightIndex(2, 3)).toBe(0) + expect(getPreviousHighlightIndex(0, 3)).toBe(2) + expect(getPreviousHighlightIndex(-1, 0)).toBe(-1) + }) + + it('hides create options for blank or duplicate values', () => { + expect(isCreateOptionVisible('', ['Draft'])).toBe(false) + expect(isCreateOptionVisible(' draft ', ['Draft'])).toBe(false) + expect(isCreateOptionVisible('Review', ['Draft'])).toBe(true) + }) + + it('keeps an anchored dropdown within the viewport margins', () => { + expect(getAnchoredDropdownLeft(300, 208, 800)).toBe(92) + expect(getAnchoredDropdownLeft(100, 208, 800)).toBe(8) + expect(getAnchoredDropdownLeft(900, 208, 800)).toBe(584) + }) +}) diff --git a/src/components/propertyDropdownUtils.ts b/src/components/propertyDropdownUtils.ts new file mode 100644 index 00000000..7cfba9b0 --- /dev/null +++ b/src/components/propertyDropdownUtils.ts @@ -0,0 +1,57 @@ +import { useEffect, useLayoutEffect, type RefObject } from 'react' + +const DEFAULT_DROPDOWN_MARGIN = 8 + +export function getAnchoredDropdownLeft( + anchorRight: number, + dropdownWidth: number, + viewportWidth: number, + margin = DEFAULT_DROPDOWN_MARGIN, +) { + const rightAlignedLeft = anchorRight - dropdownWidth + const minLeft = margin + const maxLeft = viewportWidth - dropdownWidth - margin + return Math.min(Math.max(rightAlignedLeft, minLeft), maxLeft) +} + +export function getNextHighlightIndex(current: number, total: number) { + if (total <= 0) return 0 + return current < total - 1 ? current + 1 : 0 +} + +export function getPreviousHighlightIndex(current: number, total: number) { + if (total <= 0) return -1 + return current > 0 ? current - 1 : total - 1 +} + +export function isCreateOptionVisible(query: string, options: string[]) { + const trimmed = query.trim() + if (!trimmed) return false + return !options.some((option) => option.toLowerCase() === trimmed.toLowerCase()) +} + +export function useAnchoredDropdownPosition({ + anchorRef, + dropdownRef, + width, +}: { + anchorRef: RefObject + dropdownRef: RefObject + width: number +}) { + useLayoutEffect(() => { + const node = dropdownRef.current + const anchor = anchorRef.current?.parentElement + if (!node || !anchor) return + + const rect = anchor.getBoundingClientRect() + node.style.top = `${rect.bottom + 4}px` + node.style.left = `${getAnchoredDropdownLeft(rect.right, width, window.innerWidth)}px` + }, [anchorRef, dropdownRef, width]) +} + +export function useAutoFocus(ref: RefObject) { + useEffect(() => { + ref.current?.focus() + }, [ref]) +} diff --git a/src/components/status-bar/StatusBarBadges.tsx b/src/components/status-bar/StatusBarBadges.tsx index 6623ee35..8695bd23 100644 --- a/src/components/status-bar/StatusBarBadges.tsx +++ b/src/components/status-bar/StatusBarBadges.tsx @@ -254,7 +254,7 @@ function GitStatusPopup({ borderRadius: 6, padding: 8, minWidth: 220, - boxShadow: '0 4px 12px rgba(0,0,0,0.3)', + boxShadow: '0 4px 12px var(--shadow-dialog)', zIndex: 1000, fontSize: 12, color: 'var(--foreground)', @@ -310,8 +310,8 @@ export function OfflineBadge({ isOffline }: { isOffline?: boolean }) { @@ -469,7 +469,7 @@ export function ChangesBadge({ count, onClick }: { count: number; onClick?: () = alignItems: 'center', justifyContent: 'center', background: 'var(--accent-orange)', - color: '#fff', + color: 'var(--text-inverse)', borderRadius: 9, padding: '0 5px', fontSize: 10, diff --git a/src/components/status-bar/VaultMenu.tsx b/src/components/status-bar/VaultMenu.tsx index aa8f37cb..5221bdc9 100644 --- a/src/components/status-bar/VaultMenu.tsx +++ b/src/components/status-bar/VaultMenu.tsx @@ -229,7 +229,7 @@ export function VaultMenu({ borderRadius: 6, padding: 4, minWidth: 200, - boxShadow: '0 4px 12px rgba(0,0,0,0.3)', + boxShadow: '0 4px 12px var(--shadow-dialog)', zIndex: 1000, }} > diff --git a/src/components/ui/badge.tsx b/src/components/ui/badge.tsx index db73602b..ba5c49dc 100644 --- a/src/components/ui/badge.tsx +++ b/src/components/ui/badge.tsx @@ -13,7 +13,7 @@ const badgeVariants = cva( secondary: "bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90", destructive: - "bg-destructive text-white [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60", + "bg-destructive text-destructive-foreground [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60", outline: "border-border text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground", ghost: "[a&]:hover:bg-accent [a&]:hover:text-accent-foreground", diff --git a/src/components/ui/button.tsx b/src/components/ui/button.tsx index 5fef1d15..0043766f 100644 --- a/src/components/ui/button.tsx +++ b/src/components/ui/button.tsx @@ -11,7 +11,7 @@ const buttonVariants = cva( variant: { default: "bg-primary text-primary-foreground hover:bg-primary/90", destructive: - "bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60", + "bg-destructive text-destructive-foreground hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60", outline: "border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50", secondary: diff --git a/src/extensions/frontmatterHighlight.ts b/src/extensions/frontmatterHighlight.ts index 9cf0aab6..2f697eac 100644 --- a/src/extensions/frontmatterHighlight.ts +++ b/src/extensions/frontmatterHighlight.ts @@ -73,12 +73,9 @@ export const frontmatterHighlightPlugin = ViewPlugin.fromClass( ) export function frontmatterHighlightTheme() { - const keyColor = '#c9383e' - const valueColor = '#2a7e4f' - const delimiterColor = '#c9383e' return EditorView.baseTheme({ - '.cm-frontmatter-delimiter': { color: delimiterColor, fontWeight: '600' }, - '.cm-frontmatter-key': { color: keyColor }, - '.cm-frontmatter-value': { color: valueColor }, + '.cm-frontmatter-delimiter': { color: 'var(--syntax-frontmatter-key)', fontWeight: '600' }, + '.cm-frontmatter-key': { color: 'var(--syntax-frontmatter-key)' }, + '.cm-frontmatter-value': { color: 'var(--syntax-frontmatter-value)' }, }) } diff --git a/src/extensions/markdownHighlight.ts b/src/extensions/markdownHighlight.ts index a1da8d5e..bc729e84 100644 --- a/src/extensions/markdownHighlight.ts +++ b/src/extensions/markdownHighlight.ts @@ -4,23 +4,31 @@ import { HighlightStyle, syntaxHighlighting } from '@codemirror/language' import { tags } from '@lezer/highlight' import type { Extension } from '@codemirror/state' +const SYNTAX_COLORS = { + heading: 'var(--syntax-heading)', + link: 'var(--syntax-link)', + monospace: 'var(--syntax-monospace)', + monospaceBackground: 'var(--syntax-monospace-bg)', + muted: 'var(--syntax-muted)', +} + const markdownHighlightStyle = HighlightStyle.define([ - { tag: tags.heading1, color: '#0969da', fontWeight: '700', fontSize: '1.4em' }, - { tag: tags.heading2, color: '#0969da', fontWeight: '700', fontSize: '1.25em' }, - { tag: tags.heading3, color: '#0969da', fontWeight: '600', fontSize: '1.1em' }, - { tag: tags.heading4, color: '#0969da', fontWeight: '600' }, - { tag: tags.heading5, color: '#0969da', fontWeight: '600' }, - { tag: tags.heading6, color: '#0969da', fontWeight: '600' }, + { tag: tags.heading1, color: SYNTAX_COLORS.heading, fontWeight: '700', fontSize: '1.4em' }, + { tag: tags.heading2, color: SYNTAX_COLORS.heading, fontWeight: '700', fontSize: '1.25em' }, + { tag: tags.heading3, color: SYNTAX_COLORS.heading, fontWeight: '600', fontSize: '1.1em' }, + { tag: tags.heading4, color: SYNTAX_COLORS.heading, fontWeight: '600' }, + { tag: tags.heading5, color: SYNTAX_COLORS.heading, fontWeight: '600' }, + { tag: tags.heading6, color: SYNTAX_COLORS.heading, fontWeight: '600' }, { tag: tags.strong, fontWeight: '700' }, { tag: tags.emphasis, fontStyle: 'italic' }, { tag: tags.strikethrough, textDecoration: 'line-through' }, - { tag: tags.link, color: '#0969da', textDecoration: 'underline' }, - { tag: tags.url, color: '#0969da' }, - { tag: tags.monospace, color: '#c9383e', backgroundColor: 'rgba(175,184,193,0.15)', borderRadius: '3px' }, - { tag: tags.quote, color: '#636c76', fontStyle: 'italic' }, - { tag: tags.separator, color: '#636c76' }, - { tag: tags.processingInstruction, color: '#c9383e', fontWeight: '600' }, - { tag: tags.contentSeparator, color: '#c9383e', fontWeight: '600' }, + { tag: tags.link, color: SYNTAX_COLORS.link, textDecoration: 'underline' }, + { tag: tags.url, color: SYNTAX_COLORS.link }, + { tag: tags.monospace, color: SYNTAX_COLORS.monospace, backgroundColor: SYNTAX_COLORS.monospaceBackground, borderRadius: '3px' }, + { tag: tags.quote, color: SYNTAX_COLORS.muted, fontStyle: 'italic' }, + { tag: tags.separator, color: SYNTAX_COLORS.muted }, + { tag: tags.processingInstruction, color: SYNTAX_COLORS.monospace, fontWeight: '600' }, + { tag: tags.contentSeparator, color: SYNTAX_COLORS.monospace, fontWeight: '600' }, ]) export function markdownLanguage(): Extension { diff --git a/src/hooks/useCodeMirror.ts b/src/hooks/useCodeMirror.ts index 83ec58b0..dec8d004 100644 --- a/src/hooks/useCodeMirror.ts +++ b/src/hooks/useCodeMirror.ts @@ -8,6 +8,14 @@ import { resolveArrowLigatureInput } from '../utils/arrowLigatures' import { zoomCursorFix } from '../extensions/zoomCursorFix' const FONT_FAMILY = '"JetBrains Mono", ui-monospace, "SFMono-Regular", Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace' +const RAW_EDITOR_COLORS = { + activeLineBackground: 'var(--state-hover-subtle)', + background: 'var(--surface-editor)', + foreground: 'var(--text-primary)', + gutterBackground: 'var(--surface-editor)', + gutterBorder: 'var(--border-subtle)', + gutterText: 'var(--text-muted)', +} export interface CodeMirrorCallbacks { onDocChange: (doc: string) => void @@ -17,19 +25,12 @@ export interface CodeMirrorCallbacks { } function buildBaseTheme() { - const bg = '#ffffff' - const fg = '#1e1e1e' - const gutterBg = '#ffffff' - const gutterColor = '#aaa' - const activeLineBg = 'rgba(0,100,255,0.06)' - const gutterBorder = '#eee' - return EditorView.theme({ '&': { fontSize: '13px', fontFamily: FONT_FAMILY, - backgroundColor: bg, - color: fg, + backgroundColor: RAW_EDITOR_COLORS.background, + color: RAW_EDITOR_COLORS.foreground, flex: '1', minHeight: '0', }, @@ -41,12 +42,12 @@ function buildBaseTheme() { }, '.cm-content': { padding: '0 32px 0 16px', - caretColor: fg, + caretColor: RAW_EDITOR_COLORS.foreground, }, '.cm-gutters': { - backgroundColor: gutterBg, - color: gutterColor, - borderRight: `1px solid ${gutterBorder}`, + backgroundColor: RAW_EDITOR_COLORS.gutterBackground, + color: RAW_EDITOR_COLORS.gutterText, + borderRight: `1px solid ${RAW_EDITOR_COLORS.gutterBorder}`, paddingLeft: '16px', }, '.cm-lineNumbers .cm-gutterElement': { @@ -55,10 +56,10 @@ function buildBaseTheme() { textAlign: 'right', }, '.cm-activeLine': { - backgroundColor: activeLineBg, + backgroundColor: RAW_EDITOR_COLORS.activeLineBackground, }, '.cm-activeLineGutter': { - backgroundColor: activeLineBg, + backgroundColor: RAW_EDITOR_COLORS.activeLineBackground, }, '&.cm-focused': { outline: 'none' }, '.cm-line': { padding: '0' }, diff --git a/src/hooks/useDocumentThemeMode.test.ts b/src/hooks/useDocumentThemeMode.test.ts new file mode 100644 index 00000000..d7ba8f59 --- /dev/null +++ b/src/hooks/useDocumentThemeMode.test.ts @@ -0,0 +1,29 @@ +import { act, renderHook, waitFor } from '@testing-library/react' +import { beforeEach, describe, expect, it } from 'vitest' +import { useDocumentThemeMode } from './useDocumentThemeMode' + +describe('useDocumentThemeMode', () => { + beforeEach(() => { + document.documentElement.removeAttribute('data-theme') + document.documentElement.classList.remove('dark') + }) + + it('defaults to light when no document theme is applied', () => { + const { result } = renderHook(() => useDocumentThemeMode()) + + expect(result.current).toBe('light') + }) + + it('updates when the document theme changes', async () => { + const { result } = renderHook(() => useDocumentThemeMode()) + + act(() => { + document.documentElement.setAttribute('data-theme', 'dark') + document.documentElement.classList.add('dark') + }) + + await waitFor(() => { + expect(result.current).toBe('dark') + }) + }) +}) diff --git a/src/hooks/useDocumentThemeMode.ts b/src/hooks/useDocumentThemeMode.ts new file mode 100644 index 00000000..22c468ca --- /dev/null +++ b/src/hooks/useDocumentThemeMode.ts @@ -0,0 +1,33 @@ +import { useSyncExternalStore } from 'react' +import { + DEFAULT_THEME_MODE, + normalizeThemeMode, + type ThemeMode, +} from '../lib/themeMode' + +function readDocumentThemeMode(): ThemeMode { + if (typeof document === 'undefined') return DEFAULT_THEME_MODE + return normalizeThemeMode(document.documentElement.getAttribute('data-theme')) ?? DEFAULT_THEME_MODE +} + +function subscribeDocumentThemeMode(onChange: () => void): () => void { + if (typeof document === 'undefined' || typeof MutationObserver === 'undefined') { + return () => {} + } + + const observer = new MutationObserver(onChange) + observer.observe(document.documentElement, { + attributeFilter: ['class', 'data-theme'], + attributes: true, + }) + + return () => observer.disconnect() +} + +export function useDocumentThemeMode(): ThemeMode { + return useSyncExternalStore( + subscribeDocumentThemeMode, + readDocumentThemeMode, + () => DEFAULT_THEME_MODE, + ) +} diff --git a/src/hooks/useSettings.test.ts b/src/hooks/useSettings.test.ts index 8816d80f..0eab602a 100644 --- a/src/hooks/useSettings.test.ts +++ b/src/hooks/useSettings.test.ts @@ -14,6 +14,7 @@ const defaultSettings: Settings = { analytics_enabled: null, anonymous_id: null, release_channel: null, + theme_mode: null, default_ai_agent: null, } @@ -28,6 +29,7 @@ const savedSettings: Settings = { analytics_enabled: null, anonymous_id: null, release_channel: null, + theme_mode: null, default_ai_agent: null, } @@ -111,6 +113,7 @@ describe('useSettings', () => { analytics_enabled: null, anonymous_id: null, release_channel: null, + theme_mode: null, default_ai_agent: null, } diff --git a/src/hooks/useSettings.ts b/src/hooks/useSettings.ts index 0a4bb046..8b524f01 100644 --- a/src/hooks/useSettings.ts +++ b/src/hooks/useSettings.ts @@ -3,6 +3,7 @@ import { invoke } from '@tauri-apps/api/core' import { isTauri, mockInvoke } from '../mock-tauri' import { normalizeStoredAiAgent } from '../lib/aiAgents' import { normalizeReleaseChannel, serializeReleaseChannel } from '../lib/releaseChannel' +import { normalizeThemeMode } from '../lib/themeMode' import type { Settings } from '../types' function tauriCall(command: string, tauriArgs: Record, mockArgs?: Record): Promise { @@ -20,6 +21,7 @@ const EMPTY_SETTINGS: Settings = { analytics_enabled: null, anonymous_id: null, release_channel: null, + theme_mode: null, default_ai_agent: null, } @@ -29,6 +31,7 @@ function normalizeSettings(settings: Settings): Settings { release_channel: serializeReleaseChannel( normalizeReleaseChannel(settings.release_channel), ), + theme_mode: normalizeThemeMode(settings.theme_mode), default_ai_agent: normalizeStoredAiAgent(settings.default_ai_agent), } } diff --git a/src/hooks/useThemeMode.test.ts b/src/hooks/useThemeMode.test.ts new file mode 100644 index 00000000..0bb67615 --- /dev/null +++ b/src/hooks/useThemeMode.test.ts @@ -0,0 +1,49 @@ +import { renderHook } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { THEME_MODE_STORAGE_KEY } from '../lib/themeMode' +import { useThemeMode } from './useThemeMode' + +function createStorageMock(): Storage { + const values = new Map() + return { + get length() { return values.size }, + clear: vi.fn(() => { values.clear() }), + getItem: vi.fn((key: string) => values.get(key) ?? null), + key: vi.fn((index: number) => Array.from(values.keys())[index] ?? null), + removeItem: vi.fn((key: string) => { values.delete(key) }), + setItem: vi.fn((key: string, value: string) => { values.set(key, value) }), + } +} + +describe('useThemeMode', () => { + const localStorageMock = createStorageMock() + + beforeEach(() => { + Object.defineProperty(window, 'localStorage', { value: localStorageMock, configurable: true }) + document.documentElement.removeAttribute('data-theme') + document.documentElement.classList.remove('dark') + window.localStorage.clear() + }) + + it('waits until settings have loaded', () => { + renderHook(() => useThemeMode('dark', false)) + + expect(document.documentElement).not.toHaveAttribute('data-theme') + }) + + it('applies and mirrors the loaded settings mode', () => { + renderHook(() => useThemeMode('dark', true)) + + expect(document.documentElement).toHaveAttribute('data-theme', 'dark') + expect(document.documentElement).toHaveClass('dark') + expect(window.localStorage.getItem(THEME_MODE_STORAGE_KEY)).toBe('dark') + }) + + it('uses the storage mirror when persisted settings are empty', () => { + window.localStorage.setItem(THEME_MODE_STORAGE_KEY, 'dark') + + renderHook(() => useThemeMode(null, true)) + + expect(document.documentElement).toHaveAttribute('data-theme', 'dark') + }) +}) diff --git a/src/hooks/useThemeMode.ts b/src/hooks/useThemeMode.ts new file mode 100644 index 00000000..2a7137bd --- /dev/null +++ b/src/hooks/useThemeMode.ts @@ -0,0 +1,30 @@ +import { useEffect } from 'react' +import { + applyThemeModeToDocument, + DEFAULT_THEME_MODE, + readStoredThemeMode, + writeStoredThemeMode, + type ThemeMode, +} from '../lib/themeMode' + +function resolveRuntimeThemeMode(themeMode: ThemeMode | null | undefined): ThemeMode { + if (themeMode) return themeMode + if (typeof window === 'undefined') return DEFAULT_THEME_MODE + return readStoredThemeMode(window.localStorage) ?? DEFAULT_THEME_MODE +} + +export function useThemeMode( + themeMode: ThemeMode | null | undefined, + loaded: boolean, +): void { + useEffect(() => { + if (!loaded || typeof document === 'undefined') return + + const resolvedMode = resolveRuntimeThemeMode(themeMode) + applyThemeModeToDocument(document, resolvedMode) + + if (typeof window !== 'undefined') { + writeStoredThemeMode(window.localStorage, resolvedMode) + } + }, [loaded, themeMode]) +} diff --git a/src/index.css b/src/index.css index 40d2bbdd..ce076991 100644 --- a/src/index.css +++ b/src/index.css @@ -2,7 +2,7 @@ @import "tw-animate-css"; /* ============================================================ - Theme Variables — Light mode only (dark mode removed for now) + Theme Variables — app-owned light/dark contract ============================================================ */ :root { @@ -16,67 +16,65 @@ text-rendering: optimizeLegibility; -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale; +} - /* --- shadcn theme variables (light mode) --- */ - --radius: 0.5rem; - --background: #FFFFFF; - --foreground: #37352F; - --card: #FFFFFF; - --card-foreground: #37352F; - --popover: #FFFFFF; - --popover-foreground: #37352F; - --primary: #155DFF; - --primary-foreground: #FFFFFF; - --secondary: #EBEBEA; - --secondary-foreground: #37352F; - --muted: #F0F0EF; - --muted-foreground: #787774; - --accent: #EBEBEA; - --accent-foreground: #37352F; - --destructive: #E03E3E; - --destructive-foreground: #FFFFFF; - --border: #E9E9E7; - --input: #E9E9E7; - --ring: #155DFF; - --sidebar: #F7F6F3; - --sidebar-foreground: #37352F; - --sidebar-primary: #155DFF; - --sidebar-primary-foreground: #FFFFFF; - --sidebar-accent: #EBEBEA; - --sidebar-accent-foreground: #37352F; - --sidebar-border: #E9E9E7; - --sidebar-ring: #155DFF; - - /* --- App-specific variables (light mode) --- */ +:root, +[data-theme="light"] { color-scheme: light; - --bg-primary: #FFFFFF; - --bg-sidebar: #F7F6F3; - --bg-card: #FFFFFF; - --bg-hover: #EBEBEA; - --bg-hover-subtle: #F0F0EF; - --bg-selected: #E8F4FE; - --bg-input: #FFFFFF; - --bg-button: #EBEBEA; - --bg-dialog: #FFFFFF; + + /* --- Semantic surfaces (light mode) --- */ + --surface-app: #FFFFFF; + --surface-sidebar: #F7F6F3; + --surface-panel: #FFFFFF; + --surface-card: #FFFFFF; + --surface-popover: #FFFFFF; + --surface-input: #FFFFFF; + --surface-button: #EBEBEA; + --surface-dialog: #FFFFFF; + --surface-editor: #FFFFFF; + --surface-overlay: rgba(0, 0, 0, 0.3); + + /* --- Semantic text (light mode) --- */ --text-primary: #37352F; --text-secondary: #787774; --text-tertiary: #787774; --text-muted: #B4B4B4; --text-faint: #B4B4B4; --text-heading: #37352F; + --text-inverse: #FFFFFF; + + /* --- Semantic borders (light mode) --- */ + --border-default: #E9E9E7; + --border-subtle: #E9E9E7; + --border-strong: #D9D9D6; + --border-input: #E9E9E7; + --border-dialog: #E9E9E7; + --border-focus: #155DFF; + + /* --- Interaction states (light mode) --- */ + --state-hover: #EBEBEA; + --state-hover-subtle: #F0F0EF; + --state-selected: #E8F4FE; + --state-selected-strong: #D8ECFE; + --state-active: #E8F4FE; + --state-focus-ring: #155DFF; + --state-drag-target: #155DFF18; + --state-disabled: #F0F0EF; + + /* --- Accent roles (light mode) --- */ --accent-blue: #155DFF; --accent-blue-bg: #155DFF18; --accent-blue-hover: #0D4AD6; + --accent-blue-light: #155DFF14; --accent-green: #38A169; + --accent-green-light: rgba(56, 161, 105, 0.1); --accent-orange: #D9730D; --accent-orange-light: rgba(217, 115, 13, 0.1); --accent-red: #E53E3E; - --accent-purple: #805AD5; - --accent-yellow: #D69E2E; - --accent-blue-light: #155DFF14; - --accent-green-light: rgba(56, 161, 105, 0.1); - --accent-purple-light: rgba(128, 90, 213, 0.1); --accent-red-light: rgba(229, 62, 62, 0.1); + --accent-purple: #805AD5; + --accent-purple-light: rgba(128, 90, 213, 0.1); + --accent-yellow: #D69E2E; --accent-yellow-light: rgba(214, 158, 46, 0.1); --accent-teal: #319795; --accent-teal-light: rgba(49, 151, 149, 0.1); @@ -84,17 +82,236 @@ --accent-pink-light: rgba(213, 63, 140, 0.1); --accent-gray: #718096; --accent-gray-light: rgba(113, 128, 150, 0.1); - --border-primary: #E9E9E7; - --border-subtle: #E9E9E7; - --border-input: #E9E9E7; - --border-dialog: #E9E9E7; - --link-color: #155DFF; - --link-hover: #0D4AD6; - --shadow-overlay: rgba(0, 0, 0, 0.3); + + /* --- Feedback roles (light mode) --- */ + --feedback-info-text: var(--accent-blue); + --feedback-info-bg: var(--accent-blue-light); + --feedback-success-text: var(--accent-green); + --feedback-success-bg: var(--accent-green-light); + --feedback-warning-text: #92400E; + --feedback-warning-bg: #FEF3C7; + --feedback-warning-border: #D97706; + --feedback-error-text: var(--accent-red); + --feedback-error-bg: var(--accent-red-light); + + /* --- Syntax and diff roles (light mode) --- */ + --syntax-heading: #0969DA; + --syntax-link: #0969DA; + --syntax-monospace: #C9383E; + --syntax-monospace-bg: rgba(175, 184, 193, 0.15); + --syntax-muted: #636C76; + --syntax-frontmatter-key: #C9383E; + --syntax-frontmatter-value: #2A7E4F; + --syntax-highlight-comment: #6A737D; + --syntax-highlight-keyword: #D73A49; + --syntax-highlight-string: #032F62; + --syntax-highlight-number: #005CC5; + --syntax-highlight-title: #6F42C1; + --syntax-highlight-type: #E36209; + --syntax-highlight-deletion: #B31D28; + --syntax-highlight-deletion-bg: #FFEEF0; + --diff-added-text: #4CAF50; + --diff-added-bg: rgba(76, 175, 80, 0.12); + --diff-removed-text: #F44336; + --diff-removed-bg: rgba(244, 67, 54, 0.12); + --diff-hunk-bg: rgba(33, 150, 243, 0.08); + + /* --- Overlays and shadows (light mode) --- */ + --shadow-overlay: var(--surface-overlay); --shadow-dialog: rgba(0, 0, 0, 0.15); + + /* --- shadcn theme aliases (light mode) --- */ + --radius: 0.5rem; + --background: var(--surface-app); + --foreground: var(--text-primary); + --card: var(--surface-card); + --card-foreground: var(--text-primary); + --popover: var(--surface-popover); + --popover-foreground: var(--text-primary); + --primary: var(--accent-blue); + --primary-foreground: var(--text-inverse); + --secondary: var(--state-hover); + --secondary-foreground: var(--text-primary); + --muted: var(--state-hover-subtle); + --muted-foreground: var(--text-secondary); + --accent: var(--state-hover); + --accent-foreground: var(--text-primary); + --destructive: var(--accent-red); + --destructive-foreground: var(--text-inverse); + --border: var(--border-default); + --input: var(--border-input); + --ring: var(--state-focus-ring); + --sidebar: var(--surface-sidebar); + --sidebar-foreground: var(--text-primary); + --sidebar-primary: var(--accent-blue); + --sidebar-primary-foreground: var(--text-inverse); + --sidebar-accent: var(--state-hover); + --sidebar-accent-foreground: var(--text-primary); + --sidebar-border: var(--border-default); + --sidebar-ring: var(--state-focus-ring); + + /* --- Compatibility aliases for existing product code --- */ + --bg-primary: var(--surface-app); + --bg-sidebar: var(--surface-sidebar); + --bg-card: var(--surface-card); + --bg-hover: var(--state-hover); + --bg-hover-subtle: var(--state-hover-subtle); + --bg-selected: var(--state-selected); + --bg-input: var(--surface-input); + --bg-button: var(--surface-button); + --bg-dialog: var(--surface-dialog); + --border-primary: var(--border-default); + --hover: var(--state-hover); + --link-color: var(--accent-blue); + --link-hover: var(--accent-blue-hover); } -/* Dark mode variables removed — light mode only for now */ +:root.dark, +[data-theme="dark"] { + color-scheme: dark; + + /* --- Semantic surfaces (dark mode) --- */ + --surface-app: #1F1E1B; + --surface-sidebar: #191814; + --surface-panel: #23221F; + --surface-card: #23221F; + --surface-popover: #292823; + --surface-input: #1F1E1B; + --surface-button: #34322D; + --surface-dialog: #272622; + --surface-editor: #1F1E1B; + --surface-overlay: rgba(8, 8, 7, 0.58); + + /* --- Semantic text (dark mode) --- */ + --text-primary: #E6E1D8; + --text-secondary: #B8B1A6; + --text-tertiary: #9C9488; + --text-muted: #7F776D; + --text-faint: #625B53; + --text-heading: #F1ECE3; + --text-inverse: #151411; + + /* --- Semantic borders (dark mode) --- */ + --border-default: #34322D; + --border-subtle: #2A2925; + --border-strong: #46433B; + --border-input: #3A3832; + --border-dialog: #3A3832; + --border-focus: #78A4FF; + + /* --- Interaction states (dark mode) --- */ + --state-hover: #2D2B27; + --state-hover-subtle: #262520; + --state-selected: #1E344C; + --state-selected-strong: #25415F; + --state-active: #203A55; + --state-focus-ring: #78A4FF; + --state-drag-target: rgba(120, 164, 255, 0.2); + --state-disabled: #292824; + + /* --- Accent roles (dark mode) --- */ + --accent-blue: #78A4FF; + --accent-blue-bg: rgba(120, 164, 255, 0.2); + --accent-blue-hover: #9BBEFF; + --accent-blue-light: rgba(120, 164, 255, 0.16); + --accent-green: #79D89D; + --accent-green-light: rgba(121, 216, 157, 0.16); + --accent-orange: #F3A15B; + --accent-orange-light: rgba(243, 161, 91, 0.17); + --accent-red: #FF8A86; + --accent-red-light: rgba(255, 138, 134, 0.16); + --accent-purple: #B69CFF; + --accent-purple-light: rgba(182, 156, 255, 0.17); + --accent-yellow: #F2C86B; + --accent-yellow-light: rgba(242, 200, 107, 0.18); + --accent-teal: #64D1C8; + --accent-teal-light: rgba(100, 209, 200, 0.16); + --accent-pink: #F28AC3; + --accent-pink-light: rgba(242, 138, 195, 0.16); + --accent-gray: #A7B0BD; + --accent-gray-light: rgba(167, 176, 189, 0.14); + + /* --- Feedback roles (dark mode) --- */ + --feedback-info-text: var(--accent-blue); + --feedback-info-bg: var(--accent-blue-light); + --feedback-success-text: var(--accent-green); + --feedback-success-bg: var(--accent-green-light); + --feedback-warning-text: #F5C36B; + --feedback-warning-bg: rgba(245, 195, 107, 0.16); + --feedback-warning-border: #D99B3D; + --feedback-error-text: var(--accent-red); + --feedback-error-bg: var(--accent-red-light); + + /* --- Syntax and diff roles (dark mode) --- */ + --syntax-heading: #83B2FF; + --syntax-link: #83B2FF; + --syntax-monospace: #FFA6A3; + --syntax-monospace-bg: rgba(167, 176, 189, 0.16); + --syntax-muted: #9C9488; + --syntax-frontmatter-key: #FFA6A3; + --syntax-frontmatter-value: #8EDFAE; + --syntax-highlight-comment: #8E877D; + --syntax-highlight-keyword: #FF9BA0; + --syntax-highlight-string: #A9D6FF; + --syntax-highlight-number: #8BB7FF; + --syntax-highlight-title: #C2AAFF; + --syntax-highlight-type: #F3B175; + --syntax-highlight-deletion: #FF9C9A; + --syntax-highlight-deletion-bg: rgba(255, 138, 134, 0.16); + --diff-added-text: #79D89D; + --diff-added-bg: rgba(121, 216, 157, 0.14); + --diff-removed-text: #FF8A86; + --diff-removed-bg: rgba(255, 138, 134, 0.14); + --diff-hunk-bg: rgba(120, 164, 255, 0.13); + + /* --- Overlays and shadows (dark mode) --- */ + --shadow-overlay: var(--surface-overlay); + --shadow-dialog: rgba(0, 0, 0, 0.42); + + /* --- shadcn theme aliases (dark mode) --- */ + --background: var(--surface-app); + --foreground: var(--text-primary); + --card: var(--surface-card); + --card-foreground: var(--text-primary); + --popover: var(--surface-popover); + --popover-foreground: var(--text-primary); + --primary: var(--accent-blue); + --primary-foreground: var(--text-inverse); + --secondary: var(--state-hover); + --secondary-foreground: var(--text-primary); + --muted: var(--state-hover-subtle); + --muted-foreground: var(--text-secondary); + --accent: var(--state-hover); + --accent-foreground: var(--text-primary); + --destructive: var(--accent-red); + --destructive-foreground: var(--text-inverse); + --border: var(--border-default); + --input: var(--border-input); + --ring: var(--state-focus-ring); + --sidebar: var(--surface-sidebar); + --sidebar-foreground: var(--text-primary); + --sidebar-primary: var(--accent-blue); + --sidebar-primary-foreground: var(--text-inverse); + --sidebar-accent: var(--state-hover); + --sidebar-accent-foreground: var(--text-primary); + --sidebar-border: var(--border-default); + --sidebar-ring: var(--state-focus-ring); + + /* --- Compatibility aliases for existing product code --- */ + --bg-primary: var(--surface-app); + --bg-sidebar: var(--surface-sidebar); + --bg-card: var(--surface-card); + --bg-hover: var(--state-hover); + --bg-hover-subtle: var(--state-hover-subtle); + --bg-selected: var(--state-selected); + --bg-input: var(--surface-input); + --bg-button: var(--surface-button); + --bg-dialog: var(--surface-dialog); + --border-primary: var(--border-default); + --hover: var(--state-hover); + --link-color: var(--accent-blue); + --link-hover: var(--accent-blue-hover); +} /* --- Tailwind v4 theme inline: register colors + radii --- */ @theme inline { @@ -125,6 +342,19 @@ --color-sidebar-accent-foreground: var(--sidebar-accent-foreground); --color-sidebar-border: var(--sidebar-border); --color-sidebar-ring: var(--sidebar-ring); + --color-surface-app: var(--surface-app); + --color-surface-sidebar: var(--surface-sidebar); + --color-surface-panel: var(--surface-panel); + --color-surface-card: var(--surface-card); + --color-surface-popover: var(--surface-popover); + --color-surface-editor: var(--surface-editor); + --color-state-hover: var(--state-hover); + --color-state-selected: var(--state-selected); + --color-border-default: var(--border-default); + --color-border-subtle: var(--border-subtle); + --color-text-primary: var(--text-primary); + --color-text-secondary: var(--text-secondary); + --color-text-muted: var(--text-muted); --radius-sm: calc(var(--radius) - 4px); --radius-md: calc(var(--radius) - 2px); --radius-lg: var(--radius); @@ -296,28 +526,28 @@ .ai-markdown .hljs { color: var(--foreground); } .ai-markdown .hljs-comment, -.ai-markdown .hljs-quote { color: #6a737d; font-style: italic; } +.ai-markdown .hljs-quote { color: var(--syntax-highlight-comment); font-style: italic; } .ai-markdown .hljs-keyword, -.ai-markdown .hljs-selector-tag { color: #d73a49; } +.ai-markdown .hljs-selector-tag { color: var(--syntax-highlight-keyword); } .ai-markdown .hljs-string, -.ai-markdown .hljs-addition { color: #032f62; } +.ai-markdown .hljs-addition { color: var(--syntax-highlight-string); } .ai-markdown .hljs-number, -.ai-markdown .hljs-literal { color: #005cc5; } +.ai-markdown .hljs-literal { color: var(--syntax-highlight-number); } .ai-markdown .hljs-title, -.ai-markdown .hljs-section { color: #6f42c1; } +.ai-markdown .hljs-section { color: var(--syntax-highlight-title); } .ai-markdown .hljs-built_in, -.ai-markdown .hljs-type { color: #e36209; } +.ai-markdown .hljs-type { color: var(--syntax-highlight-type); } .ai-markdown .hljs-attr, .ai-markdown .hljs-name, -.ai-markdown .hljs-attribute { color: #005cc5; } -.ai-markdown .hljs-deletion { color: #b31d28; background: #ffeef0; } -.ai-markdown .hljs-meta { color: #6a737d; } +.ai-markdown .hljs-attribute { color: var(--syntax-highlight-number); } +.ai-markdown .hljs-deletion { color: var(--syntax-highlight-deletion); background: var(--syntax-highlight-deletion-bg); } +.ai-markdown .hljs-meta { color: var(--syntax-highlight-comment); } /* --- AI panel working border animation --- */ @keyframes ai-border-pulse { - 0%, 100% { border-color: var(--accent-blue, #3b82f6); opacity: 1; } - 50% { border-color: var(--accent-blue, #93c5fd); opacity: 0.6; } + 0%, 100% { border-color: var(--accent-blue); opacity: 1; } + 50% { border-color: var(--accent-blue-hover); opacity: 0.6; } } /* --- Typing indicator dots --- */ diff --git a/src/lib/themeMode.test.ts b/src/lib/themeMode.test.ts new file mode 100644 index 00000000..49edb9cb --- /dev/null +++ b/src/lib/themeMode.test.ts @@ -0,0 +1,66 @@ +import { describe, expect, it, vi } from 'vitest' +import { + applyStoredThemeMode, + applyThemeModeToDocument, + LEGACY_THEME_MODE_STORAGE_KEY, + normalizeThemeMode, + readStoredThemeMode, + resolveThemeMode, + THEME_MODE_STORAGE_KEY, + writeStoredThemeMode, +} from './themeMode' + +function makeStorage(initial: Record = {}): Storage { + const values = new Map(Object.entries(initial)) + return { + get length() { return values.size }, + clear: vi.fn(() => values.clear()), + getItem: vi.fn((key: string) => values.get(key) ?? null), + key: vi.fn((index: number) => Array.from(values.keys())[index] ?? null), + removeItem: vi.fn((key: string) => { values.delete(key) }), + setItem: vi.fn((key: string, value: string) => { values.set(key, value) }), + } +} + +describe('themeMode', () => { + it('normalizes only supported theme modes', () => { + expect(normalizeThemeMode('light')).toBe('light') + expect(normalizeThemeMode('dark')).toBe('dark') + expect(normalizeThemeMode('system')).toBeNull() + expect(resolveThemeMode('system')).toBe('light') + }) + + it('reads and writes the current storage key', () => { + const storage = makeStorage() + + writeStoredThemeMode(storage, 'dark') + + expect(readStoredThemeMode(storage)).toBe('dark') + expect(storage.setItem).toHaveBeenCalledWith(THEME_MODE_STORAGE_KEY, 'dark') + }) + + it('migrates the legacy storage key', () => { + const storage = makeStorage({ [LEGACY_THEME_MODE_STORAGE_KEY]: 'dark' }) + + expect(readStoredThemeMode(storage)).toBe('dark') + expect(storage.setItem).toHaveBeenCalledWith(THEME_MODE_STORAGE_KEY, 'dark') + }) + + it('applies theme attributes and shadcn dark class', () => { + applyThemeModeToDocument(document, 'dark') + expect(document.documentElement).toHaveAttribute('data-theme', 'dark') + expect(document.documentElement).toHaveClass('dark') + + applyThemeModeToDocument(document, 'light') + expect(document.documentElement).toHaveAttribute('data-theme', 'light') + expect(document.documentElement).not.toHaveClass('dark') + }) + + it('bootstraps stored theme mode onto the document', () => { + const storage = makeStorage({ [THEME_MODE_STORAGE_KEY]: 'dark' }) + + expect(applyStoredThemeMode(document, storage)).toBe('dark') + expect(document.documentElement).toHaveAttribute('data-theme', 'dark') + expect(document.documentElement).toHaveClass('dark') + }) +}) diff --git a/src/lib/themeMode.ts b/src/lib/themeMode.ts new file mode 100644 index 00000000..f4c6557b --- /dev/null +++ b/src/lib/themeMode.ts @@ -0,0 +1,66 @@ +import { APP_STORAGE_KEYS, LEGACY_APP_STORAGE_KEYS } from '../constants/appStorage' + +export const THEME_MODE_STORAGE_KEY = APP_STORAGE_KEYS.theme +export const LEGACY_THEME_MODE_STORAGE_KEY = LEGACY_APP_STORAGE_KEYS.theme +export const DEFAULT_THEME_MODE = 'light' + +const THEME_MODES = new Set(['light', 'dark']) + +export type ThemeMode = 'light' | 'dark' + +type ThemeStorage = Pick +type ThemeDocument = Pick + +export function normalizeThemeMode(value: unknown): ThemeMode | null { + return typeof value === 'string' && THEME_MODES.has(value) ? value as ThemeMode : null +} + +export function resolveThemeMode(value: unknown): ThemeMode { + return normalizeThemeMode(value) ?? DEFAULT_THEME_MODE +} + +function safeGetThemeMode(storage: ThemeStorage, key: string): ThemeMode | null { + try { + return normalizeThemeMode(storage.getItem(key)) + } catch { + return null + } +} + +function safeSetThemeMode(storage: ThemeStorage, key: string, mode: ThemeMode): void { + try { + storage.setItem(key, mode) + } catch { + // Storage can be unavailable in restricted browser contexts. + } +} + +export function readStoredThemeMode(storage: ThemeStorage): ThemeMode | null { + const storedMode = safeGetThemeMode(storage, THEME_MODE_STORAGE_KEY) + if (storedMode) return storedMode + + const legacyMode = safeGetThemeMode(storage, LEGACY_THEME_MODE_STORAGE_KEY) + if (!legacyMode) return null + + safeSetThemeMode(storage, THEME_MODE_STORAGE_KEY, legacyMode) + return legacyMode +} + +export function writeStoredThemeMode(storage: ThemeStorage, mode: ThemeMode): void { + safeSetThemeMode(storage, THEME_MODE_STORAGE_KEY, mode) +} + +export function applyThemeModeToDocument(documentObject: ThemeDocument, mode: ThemeMode): void { + const root = documentObject.documentElement + root.setAttribute('data-theme', mode) + root.classList.toggle('dark', mode === 'dark') +} + +export function applyStoredThemeMode( + documentObject: ThemeDocument, + storage: ThemeStorage, +): ThemeMode { + const mode = readStoredThemeMode(storage) ?? DEFAULT_THEME_MODE + applyThemeModeToDocument(documentObject, mode) + return mode +} diff --git a/src/main.tsx b/src/main.tsx index 587e41e8..7dec705d 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -5,6 +5,7 @@ import { TooltipProvider } from '@/components/ui/tooltip' import './index.css' import App from './App.tsx' import { LinuxTitlebar } from './components/LinuxTitlebar' +import { applyStoredThemeMode } from './lib/themeMode' import { APP_COMMAND_EVENT_NAME, isAppCommandId, @@ -29,6 +30,8 @@ if (shouldUseLinuxWindowChrome()) { document.body.classList.add('linux-chrome') } +applyStoredThemeMode(document, window.localStorage) + function dispatchDeterministicShortcutEvent(init: AppCommandShortcutEventInit) { const target = document.activeElement instanceof HTMLElement diff --git a/src/mock-tauri/mock-handlers.coverage.test.ts b/src/mock-tauri/mock-handlers.coverage.test.ts index 0db97da1..f0de76a0 100644 --- a/src/mock-tauri/mock-handlers.coverage.test.ts +++ b/src/mock-tauri/mock-handlers.coverage.test.ts @@ -167,6 +167,7 @@ describe('mockHandlers coverage', () => { analytics_enabled: true, anonymous_id: 'anon-1', release_channel: 'alpha', + theme_mode: null, default_ai_agent: 'codex', }) diff --git a/src/mock-tauri/mock-handlers.more.test.ts b/src/mock-tauri/mock-handlers.more.test.ts index 38f70742..1fc0c6d8 100644 --- a/src/mock-tauri/mock-handlers.more.test.ts +++ b/src/mock-tauri/mock-handlers.more.test.ts @@ -152,6 +152,22 @@ describe('mockHandlers additional coverage', () => { expect(mockHandlers.repair_vault()).toBe('Vault repaired') }) + it('persists theme mode through the mock settings backend', async () => { + const { mockHandlers } = await loadHandlers() + const settings = mockHandlers.get_settings() + + mockHandlers.save_settings({ + settings: { + ...settings, + theme_mode: 'dark', + }, + }) + + expect(mockHandlers.get_settings()).toEqual(expect.objectContaining({ + theme_mode: 'dark', + })) + }) + it('surfaces the simple command handlers for git, conflicts, trash, and telemetry', async () => { const { mockHandlers } = await loadHandlers() diff --git a/src/mock-tauri/mock-handlers.ts b/src/mock-tauri/mock-handlers.ts index 700f7518..2816f868 100644 --- a/src/mock-tauri/mock-handlers.ts +++ b/src/mock-tauri/mock-handlers.ts @@ -111,6 +111,7 @@ let mockSettings: Settings = { analytics_enabled: null, anonymous_id: null, release_channel: null, + theme_mode: null, default_ai_agent: 'claude_code', } @@ -416,6 +417,7 @@ export const mockHandlers: Record any> = { analytics_enabled: s.analytics_enabled, anonymous_id: s.anonymous_id, release_channel: s.release_channel, + theme_mode: s.theme_mode ?? null, default_ai_agent: s.default_ai_agent ?? null, } return null diff --git a/src/theme.json b/src/theme.json index 323b254e..60568e6b 100644 --- a/src/theme.json +++ b/src/theme.json @@ -49,7 +49,7 @@ "lists": { "bulletSymbol": "•", "bulletSize": 24, - "bulletColor": "#177bfd", + "bulletColor": "var(--accent-blue)", "indentSize": 24, "itemSpacing": 4, "paddingLeft": 8, diff --git a/src/types.ts b/src/types.ts index 3c698cab..0c15423d 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,4 +1,5 @@ import type { AiAgentId } from './lib/aiAgents' +import type { ThemeMode } from './lib/themeMode' export interface VaultEntry { path: string @@ -89,6 +90,7 @@ export interface Settings { analytics_enabled: boolean | null anonymous_id: string | null release_channel: string | null + theme_mode?: ThemeMode | null initial_h1_auto_rename_enabled?: boolean | null default_ai_agent?: AiAgentId | null } diff --git a/src/utils/releaseDownloadPage.test.ts b/src/utils/releaseDownloadPage.test.ts index 12bf7106..d01235f8 100644 --- a/src/utils/releaseDownloadPage.test.ts +++ b/src/utils/releaseDownloadPage.test.ts @@ -58,6 +58,9 @@ describe('buildStableDownloadRedirectPage', () => { expect(html).toContain('Download Tolaria for Windows') expect(html).toContain('Download Tolaria for macOS') expect(html).toContain('window.location.replace') + expect(html).toContain('color-scheme: light dark') + expect(html).toContain('@media (prefers-color-scheme: dark)') + expect(html).toContain('background: var(--download-surface-page)') }) it('builds a fallback page when no stable downloads exist yet', () => { diff --git a/src/utils/releaseDownloadPage.ts b/src/utils/releaseDownloadPage.ts index 43507753..4274a16b 100644 --- a/src/utils/releaseDownloadPage.ts +++ b/src/utils/releaseDownloadPage.ts @@ -62,8 +62,35 @@ const PLATFORM_ORDER: StablePlatformKey[] = [ const REDIRECT_PAGE_STYLES = ` :root { - color-scheme: light; + color-scheme: light dark; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; + --download-surface-page: #f7f6f3; + --download-surface-card: #ffffff; + --download-text-primary: #37352f; + --download-border-default: #e9e9e7; + --download-accent: #155dff; + --download-accent-hover: #1248cc; + --download-secondary-bg: #eef2ff; + --download-secondary-hover-bg: #dbe4ff; + --download-secondary-text: #1d4ed8; + --download-text-on-accent: #ffffff; + --download-shadow-card: rgba(15, 23, 42, 0.08); + } + + @media (prefers-color-scheme: dark) { + :root { + --download-surface-page: #1f1e1b; + --download-surface-card: #23221f; + --download-text-primary: #e6e1d8; + --download-border-default: #34322d; + --download-accent: #78a4ff; + --download-accent-hover: #9bbeff; + --download-secondary-bg: #34322d; + --download-secondary-hover-bg: #46433b; + --download-secondary-text: #e6e1d8; + --download-text-on-accent: #151411; + --download-shadow-card: rgba(0, 0, 0, 0.35); + } } * { @@ -76,17 +103,17 @@ const REDIRECT_PAGE_STYLES = ` display: grid; place-items: center; padding: 24px; - background: #f7f6f3; - color: #37352f; + background: var(--download-surface-page); + color: var(--download-text-primary); } main { width: min(100%, 520px); - background: #ffffff; - border: 1px solid #e9e9e7; + background: var(--download-surface-card); + border: 1px solid var(--download-border-default); border-radius: 16px; padding: 24px; - box-shadow: 0 16px 40px rgba(15, 23, 42, 0.08); + box-shadow: 0 16px 40px var(--download-shadow-card); } h1 { @@ -114,25 +141,25 @@ const REDIRECT_PAGE_STYLES = ` min-height: 44px; padding: 0 16px; border-radius: 10px; - background: #155dff; - color: #ffffff; + background: var(--download-accent); + color: var(--download-text-on-accent); text-decoration: none; font-weight: 600; } a[data-secondary="true"] { - background: #eef2ff; - color: #1d4ed8; + background: var(--download-secondary-bg); + color: var(--download-secondary-text); } a:hover, a:focus-visible { - background: #1248cc; + background: var(--download-accent-hover); } a[data-secondary="true"]:hover, a[data-secondary="true"]:focus-visible { - background: #dbe4ff; + background: var(--download-secondary-hover-bg); } ` diff --git a/src/utils/releaseHistoryPage.test.ts b/src/utils/releaseHistoryPage.test.ts index 94430608..a69d7395 100644 --- a/src/utils/releaseHistoryPage.test.ts +++ b/src/utils/releaseHistoryPage.test.ts @@ -39,6 +39,9 @@ describe('buildReleaseHistoryPage', () => { expect(html).toContain('id="tab-stable"') expect(html).toContain('aria-selected="true"') expect(html).toContain('data-release-panel="alpha" hidden') + expect(html).toContain('color-scheme: light dark') + expect(html).toContain('@media (prefers-color-scheme: dark)') + expect(html).toContain('background: var(--release-surface-page)') expect(html).toContain('

Highlights

') expect(html).toContain('
  • Faster startup
  • ') expect(html).toContain('Alpha notes') diff --git a/src/utils/releaseHistoryPage.ts b/src/utils/releaseHistoryPage.ts index 675618bb..4e9d8546 100644 --- a/src/utils/releaseHistoryPage.ts +++ b/src/utils/releaseHistoryPage.ts @@ -36,8 +36,51 @@ type ReleaseSections = Record const RELEASE_HISTORY_PAGE_STYLES = ` :root { - color-scheme: light; + color-scheme: light dark; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; + --release-surface-page: #f7f6f3; + --release-surface-card: #ffffff; + --release-surface-muted: #f4f4f2; + --release-surface-tab-hover: rgba(21, 93, 255, 0.06); + --release-surface-secondary-action: #ebeef5; + --release-text-primary: #37352f; + --release-text-secondary: #787774; + --release-text-muted: #5e5c57; + --release-text-body: #44403c; + --release-text-blockquote: #57534e; + --release-text-on-accent: #ffffff; + --release-border-default: #e9e9e7; + --release-border-strong: #d6d3d1; + --release-border-accent: #d8e3ff; + --release-accent: #155dff; + --release-alpha: #f59e0b; + --release-alpha-bg: #fff3d6; + --release-alpha-text: #b45309; + --release-shadow-card: rgba(15, 23, 42, 0.05); + } + + @media (prefers-color-scheme: dark) { + :root { + --release-surface-page: #1f1e1b; + --release-surface-card: #23221f; + --release-surface-muted: #2d2b27; + --release-surface-tab-hover: rgba(120, 164, 255, 0.16); + --release-surface-secondary-action: #34322d; + --release-text-primary: #e6e1d8; + --release-text-secondary: #b8b1a6; + --release-text-muted: #c8c1b6; + --release-text-body: #d8d1c6; + --release-text-blockquote: #b8b1a6; + --release-text-on-accent: #151411; + --release-border-default: #34322d; + --release-border-strong: #46433b; + --release-border-accent: #25415f; + --release-accent: #78a4ff; + --release-alpha: #f3a15b; + --release-alpha-bg: rgba(243, 161, 91, 0.17); + --release-alpha-text: #f3c27f; + --release-shadow-card: rgba(0, 0, 0, 0.35); + } } * { @@ -47,8 +90,8 @@ const RELEASE_HISTORY_PAGE_STYLES = ` body { margin: 0; min-height: 100vh; - background: #f7f6f3; - color: #37352f; + background: var(--release-surface-page); + color: var(--release-text-primary); padding: 32px 20px 48px; } @@ -70,7 +113,7 @@ const RELEASE_HISTORY_PAGE_STYLES = ` .subtitle, .keyboard-hint { margin: 0; - color: #787774; + color: var(--release-text-secondary); line-height: 1.6; } @@ -81,7 +124,7 @@ const RELEASE_HISTORY_PAGE_STYLES = ` .channel-tabs { margin-bottom: 24px; - border-bottom: 1px solid #e9e9e7; + border-bottom: 1px solid var(--release-border-default); } .channel-tablist { @@ -97,7 +140,7 @@ const RELEASE_HISTORY_PAGE_STYLES = ` border-bottom: none; border-radius: 12px 12px 0 0; background: transparent; - color: #5e5c57; + color: var(--release-text-muted); cursor: pointer; font: inherit; font-weight: 600; @@ -105,24 +148,24 @@ const RELEASE_HISTORY_PAGE_STYLES = ` } .channel-tab:hover { - background: rgba(21, 93, 255, 0.06); - color: #155dff; + background: var(--release-surface-tab-hover); + color: var(--release-accent); } .channel-tab:focus-visible { - outline: 2px solid #155dff; + outline: 2px solid var(--release-accent); outline-offset: 2px; } .channel-tab[aria-selected="true"] { - background: #ffffff; - border-color: #d8e3ff; - color: #155dff; - box-shadow: 0 -1px 0 #ffffff inset; + background: var(--release-surface-card); + border-color: var(--release-border-accent); + color: var(--release-accent); + box-shadow: 0 -1px 0 var(--release-surface-card) inset; } .tab-count { - color: #787774; + color: var(--release-text-secondary); font-weight: 500; margin-left: 6px; } @@ -138,19 +181,19 @@ const RELEASE_HISTORY_PAGE_STYLES = ` } .release-card { - background: #ffffff; - border: 1px solid #e9e9e7; + background: var(--release-surface-card); + border: 1px solid var(--release-border-default); border-radius: 18px; padding: 20px 22px; - box-shadow: 0 16px 40px rgba(15, 23, 42, 0.05); + box-shadow: 0 16px 40px var(--release-shadow-card); } .release-card--alpha { - border-left: 4px solid #f59e0b; + border-left: 4px solid var(--release-alpha); } .release-card--stable { - border-left: 4px solid #155dff; + border-left: 4px solid var(--release-accent); } .release-header { @@ -169,15 +212,15 @@ const RELEASE_HISTORY_PAGE_STYLES = ` .release-meta { margin: 4px 0 0; - color: #787774; + color: var(--release-text-secondary); font-size: 0.9375rem; } .release-channel { align-self: start; - background: #f1f5ff; + background: var(--release-surface-tab-hover); border-radius: 999px; - color: #155dff; + color: var(--release-accent); font-size: 0.8125rem; font-weight: 700; letter-spacing: 0.02em; @@ -186,12 +229,12 @@ const RELEASE_HISTORY_PAGE_STYLES = ` } .release-card--alpha .release-channel { - background: #fff3d6; - color: #b45309; + background: var(--release-alpha-bg); + color: var(--release-alpha-text); } .release-notes { - color: #44403c; + color: var(--release-text-body); font-size: 0.98rem; line-height: 1.7; } @@ -225,14 +268,14 @@ const RELEASE_HISTORY_PAGE_STYLES = ` } .release-notes code { - background: #f4f4f2; + background: var(--release-surface-muted); border-radius: 6px; padding: 0.12em 0.35em; } .release-notes pre { overflow-x: auto; - background: #f4f4f2; + background: var(--release-surface-muted); border-radius: 12px; padding: 14px; } @@ -243,14 +286,14 @@ const RELEASE_HISTORY_PAGE_STYLES = ` } .release-notes blockquote { - border-left: 3px solid #d6d3d1; - color: #57534e; + border-left: 3px solid var(--release-border-strong); + color: var(--release-text-blockquote); padding-left: 14px; } .release-notes a, .release-downloads a { - color: #155dff; + color: var(--release-accent); text-decoration-thickness: 0.08em; text-underline-offset: 0.18em; } @@ -269,15 +312,15 @@ const RELEASE_HISTORY_PAGE_STYLES = ` min-height: 42px; padding: 0 14px; border-radius: 10px; - background: #155dff; - color: #ffffff; + background: var(--release-accent); + color: var(--release-text-on-accent); font-weight: 600; text-decoration: none; } .release-downloads a[data-secondary="true"] { - background: #ebeef5; - color: #37352f; + background: var(--release-surface-secondary-action); + color: var(--release-text-primary); } .release-downloads a:hover, @@ -286,15 +329,15 @@ const RELEASE_HISTORY_PAGE_STYLES = ` } .release-downloads a:focus-visible { - outline: 2px solid #155dff; + outline: 2px solid var(--release-accent); outline-offset: 2px; } .empty-state { - background: #ffffff; - border: 1px dashed #d6d3d1; + background: var(--release-surface-card); + border: 1px dashed var(--release-border-strong); border-radius: 18px; - color: #787774; + color: var(--release-text-secondary); padding: 28px 24px; text-align: center; }