diff --git a/docs/ABSTRACTIONS.md b/docs/ABSTRACTIONS.md index dd5eb260..fbc52964 100644 --- a/docs/ABSTRACTIONS.md +++ b/docs/ABSTRACTIONS.md @@ -658,12 +658,12 @@ Typed ASCII arrow sequences are normalized consistently in both editor modes: ## Styling -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. +The app uses internal light and dark themes owned by Tolaria, with System as an installation-local preference that follows the OS appearance (see [ADR-0081](adr/0081-internal-light-dark-theme-runtime.md) and [ADR-0112](adr/0112-system-theme-mode.md)). The previous vault-authored theming system remains removed. 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 -4. **Theme mode commands**: Command-palette actions for light and dark mode call the same `saveSettings` path as the Settings panel and persist only `settings.theme_mode` +3. **Runtime theme bridge**: Resolves the selected preference to `light` / `dark`, applies `data-theme` and `.dark` for shadcn/ui, and subscribes to `prefers-color-scheme` while System is selected +4. **Theme mode commands**: Command-palette actions for Light, Dark, and System call the same `saveSettings` path as the Settings panel and persist only `settings.theme_mode` ## Localization @@ -794,7 +794,7 @@ 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 + theme_mode: 'light' | 'dark' | 'system' | null ui_language: AppLocale | null note_width_mode: 'normal' | 'wide' | null sidebar_type_pluralization_enabled: boolean | null // null = default true @@ -808,7 +808,7 @@ interface Settings { } ``` -Managed by `useSettings` hook and `SettingsPanel` component. `theme_mode` is installation-local because it controls device comfort rather than vault structure; the Settings panel and command-palette light/dark actions both update that same value. `ui_language` is also installation-local: `null` follows the supported system language with English fallback, while explicit values pin the UI language for this installation. Stored legacy aliases such as `zh-Hans` are normalized to canonical locale codes before the setting reaches React state. `note_width_mode` is the installation-local default for rich-editor note width; individual notes can override it with `_width` when they already have frontmatter. `sidebar_type_pluralization_enabled` is installation-local and defaults to `true`; when false, type rows use exact type names unless the type document defines an explicit `sidebar_label` override. `default_ai_agent` remains the legacy installation-local CLI fallback. `default_ai_target` is the active AI target used by the AI panel and status bar; it can point at a coding agent or a configured direct model. `ai_model_providers` stores non-secret provider metadata for local/API model targets, while hosted API keys live in Tolaria's local app-data secrets file or user-managed environment variables instead of being persisted in app settings. Provider defaults and local/API grouping come from the shared `src/shared/aiModelProviderCatalog.json` catalog used by both renderer settings and the Tauri direct-model runtime. `hide_gitignored_files` is also installation-local and defaults to `true`; changing it reloads entries, search, saved views, and folders without restarting. The `all_notes_show_pdfs`, `all_notes_show_images`, and `all_notes_show_unsupported` flags are installation-local All Notes category toggles that default off and update the list/counts without changing vault files. 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; the Settings panel and command-palette Light/Dark/System actions both update that same value. `system` remains a stored preference, while the runtime resolves it to `light` or `dark` for `data-theme` and app consumers. `ui_language` is also installation-local: `null` follows the supported system language with English fallback, while explicit values pin the UI language for this installation. Stored legacy aliases such as `zh-Hans` are normalized to canonical locale codes before the setting reaches React state. `note_width_mode` is the installation-local default for rich-editor note width; individual notes can override it with `_width` when they already have frontmatter. `sidebar_type_pluralization_enabled` is installation-local and defaults to `true`; when false, type rows use exact type names unless the type document defines an explicit `sidebar_label` override. `default_ai_agent` remains the legacy installation-local CLI fallback. `default_ai_target` is the active AI target used by the AI panel and status bar; it can point at a coding agent or a configured direct model. `ai_model_providers` stores non-secret provider metadata for local/API model targets, while hosted API keys live in Tolaria's local app-data secrets file or user-managed environment variables instead of being persisted in app settings. Provider defaults and local/API grouping come from the shared `src/shared/aiModelProviderCatalog.json` catalog used by both renderer settings and the Tauri direct-model runtime. `hide_gitignored_files` is also installation-local and defaults to `true`; changing it reloads entries, search, saved views, and folders without restarting. The `all_notes_show_pdfs`, `all_notes_show_images`, and `all_notes_show_unsupported` flags are installation-local All Notes category toggles that default off and update the list/counts without changing vault files. 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 913a9e0a..17ba1342 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -452,11 +452,11 @@ flowchart TD ## Styling -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. +The app uses internal app-owned light and dark themes with an optional System preference (see [ADR-0081](adr/0081-internal-light-dark-theme-runtime.md) and [ADR-0112](adr/0112-system-theme-mode.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`): 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. Settings and command-palette theme actions both write the same installation-local `settings.theme_mode` value. +3. **Theme runtime**: Applies resolved `light` / `dark` values to `data-theme` and the shadcn-compatible `.dark` class before React consumers render, with a localStorage mirror to avoid startup flash when dark mode or System-on-dark is selected. Settings and command-palette theme actions both write the same installation-local `settings.theme_mode` value; `system` subscribes to `prefers-color-scheme` changes at runtime while explicit Light/Dark remain overrides. ## Localization diff --git a/docs/GETTING-STARTED.md b/docs/GETTING-STARTED.md index c0f6148e..9d9d0fc3 100644 --- a/docs/GETTING-STARTED.md +++ b/docs/GETTING-STARTED.md @@ -316,7 +316,7 @@ tolaria/ | File | Why it matters | |------|---------------| -| `src/index.css` | Semantic CSS custom properties for app-owned light/dark themes. | +| `src/index.css` | Semantic CSS custom properties for app-owned light/dark themes; System mode resolves to one of these at runtime. | | `src/theme.json` | Editor-specific typography theme (fonts, headings, lists, code blocks). | ### Settings & Config @@ -364,7 +364,7 @@ type SidebarSelection = ### Command Registry -`useCommandRegistry` + `useAppCommands` build a centralized command registry. Commands are registered with labels, shortcuts, and handlers. The `CommandPalette` (Cmd+K) fuzzy-searches this registry. Settings commands can update installation-local preferences directly when they reuse an existing settings path, such as the light/dark theme-mode actions writing `settings.theme_mode`. Shortcut combos live in `appCommandCatalog.ts`; real keypresses always flow through `useAppKeyboard`, native menu clicks emit the same command IDs through `useMenuEvents`, and `appCommandDispatcher.ts` suppresses the duplicate native/renderer echo from a single shortcut. Plain-text paste follows this same path: the command owns `Cmd+Shift+V`, the menu and palette expose the same action, and `plainTextPaste.ts` resolves the active rich/raw editor target or focused text control before reading clipboard text. On macOS, any browser-reserved chord that WKWebView swallows before that path must also be added to the narrow `tauri-plugin-prevent-default` registration in `src-tauri/src/lib.rs`. On Windows, native menu clicks arrive from the main `WebviewWindow`, so `src-tauri/src/menu.rs` must keep its window-scoped menu event handler in addition to the app-level handler. On Linux, `LinuxTitlebar.tsx` and `LinuxMenuButton.tsx` reuse the same command IDs through `trigger_menu_command` because the native GTK menu bar is intentionally not mounted. The same shortcut manifest also declares the deterministic QA mode for each shortcut-capable command. +`useCommandRegistry` + `useAppCommands` build a centralized command registry. Commands are registered with labels, shortcuts, and handlers. The `CommandPalette` (Cmd+K) fuzzy-searches this registry. Settings commands can update installation-local preferences directly when they reuse an existing settings path, such as the Light/Dark/System theme-mode actions writing `settings.theme_mode`. Shortcut combos live in `appCommandCatalog.ts`; real keypresses always flow through `useAppKeyboard`, native menu clicks emit the same command IDs through `useMenuEvents`, and `appCommandDispatcher.ts` suppresses the duplicate native/renderer echo from a single shortcut. Plain-text paste follows this same path: the command owns `Cmd+Shift+V`, the menu and palette expose the same action, and `plainTextPaste.ts` resolves the active rich/raw editor target or focused text control before reading clipboard text. On macOS, any browser-reserved chord that WKWebView swallows before that path must also be added to the narrow `tauri-plugin-prevent-default` registration in `src-tauri/src/lib.rs`. On Windows, native menu clicks arrive from the main `WebviewWindow`, so `src-tauri/src/menu.rs` must keep its window-scoped menu event handler in addition to the app-level handler. On Linux, `LinuxTitlebar.tsx` and `LinuxMenuButton.tsx` reuse the same command IDs through `trigger_menu_command` because the native GTK menu bar is intentionally not mounted. The same shortcut manifest also declares the deterministic QA mode for each shortcut-capable command. Commands whose availability depends on the current note or Git state must also flow through `update_menu_state` so the native menu stays in sync with the command palette. The deleted-note restore action in Changes view is the reference example: the row opens a deleted diff preview, the command palette exposes "Restore Deleted Note", and the Note menu enables the same action only while that preview is active. diff --git a/docs/adr/0112-system-theme-mode.md b/docs/adr/0112-system-theme-mode.md new file mode 100644 index 00000000..06bba850 --- /dev/null +++ b/docs/adr/0112-system-theme-mode.md @@ -0,0 +1,41 @@ +--- +type: ADR +id: "0112" +title: "System theme mode" +status: active +date: 2026-05-05 +--- + +## Context + +ADR-0081 introduced Tolaria's internal app-owned light and dark theme runtime and deliberately deferred system-follow mode. That kept the first dark-mode release small, but users now need Tolaria to match the operating system appearance automatically, including scheduled macOS light/dark changes. + +The previous constraints still apply: themes are app-owned, not vault-authored; the renderer must avoid startup flashes; shadcn/ui, Tailwind variables, editor chrome, and secondary windows must keep sharing the same resolved light/dark contract. + +## Decision + +**Tolaria now treats `system` as a persisted theme preference that resolves to the current OS light/dark appearance at runtime.** + +The selected preference can be `light`, `dark`, or `system`: + +1. `settings.theme_mode` remains the source of truth for the installation-local preference. +2. The localStorage mirror stores the selected preference, including `system`, so the `index.html` prepaint script can resolve the correct appearance before React mounts. +3. `data-theme` and the shadcn `.dark` class always receive the resolved app theme, `light` or `dark`; they never receive `system`. +4. When `system` is selected, the renderer subscribes to `prefers-color-scheme` changes and reapplies the resolved theme without reopening the app. +5. Explicit `light` and `dark` choices remain overrides and do not follow OS changes. + +Command-palette theme actions and the Settings panel both save the same preference path. Product analytics record preference changes with the selected mode only, without sending vault or note content. + +## Alternatives considered + +- **Persist `system` and resolve it into the existing light/dark runtime** (chosen): keeps ADR-0081's small app-owned theme surface while adding OS-follow behavior. +- **Store the resolved OS theme in settings**: avoids a third stored value, but silently converts System users into explicit Light/Dark users after every save. +- **Set `data-theme="system"` and branch in CSS**: would require every theme consumer to understand a third state and would break existing Tailwind/shadcn dark-mode assumptions. +- **Rely only on CSS `prefers-color-scheme` media queries**: helps static CSS, but does not update JavaScript consumers, command state, editor integrations, or the localStorage startup mirror consistently. + +## Consequences + +- Startup still avoids a light flash when the stored preference is `system` and the OS is dark. +- Secondary windows that mount the shared theme hook receive the same resolved appearance and update on OS changes. +- Code that reads `document.documentElement.dataset.theme` must treat it as a resolved `light` or `dark` value, not as the stored user preference. +- Future theme variants should preserve this split between selected preference and resolved app theme rather than widening `data-theme` to non-renderable preference values. diff --git a/docs/adr/README.md b/docs/adr/README.md index 2de78fe3..0a674fc5 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -165,3 +165,4 @@ proposed → active → superseded | [0109](0109-debounced-worker-derived-editor-indexes.md) | Debounced worker-derived editor indexes | active | | [0110](0110-in-app-media-and-pdf-file-previews.md) | In-app media and PDF previews for binary vault files | active | | [0111](0111-path-aware-external-vault-refresh-with-focused-editor-preservation.md) | Path-aware external vault refresh with focused-editor preservation | active | +| [0112](0112-system-theme-mode.md) | System theme mode | active | diff --git a/index.html b/index.html index 5469e619..2dffca6f 100644 --- a/index.html +++ b/index.html @@ -30,11 +30,23 @@ (function () { var key = 'tolaria-theme'; var legacyKey = 'laputa-theme'; + var systemDarkQuery = '(prefers-color-scheme: dark)'; function normalizeTheme(value) { - return value === 'dark' || value === 'light' ? value : null; + return value === 'dark' || value === 'light' || value === 'system' ? value : null; + } + function prefersDarkTheme() { + try { + return typeof window.matchMedia === 'function' && window.matchMedia(systemDarkQuery).matches; + } catch (err) { + return false; + } + } + function resolveTheme(value) { + var mode = normalizeTheme(value) || 'light'; + return mode === 'system' ? (prefersDarkTheme() ? 'dark' : 'light') : mode; } function applyTheme(value) { - var mode = normalizeTheme(value) || 'light'; + var mode = resolveTheme(value); document.documentElement.setAttribute('data-theme', mode); document.documentElement.classList.toggle('dark', mode === 'dark'); } diff --git a/lara.lock b/lara.lock index 322385f6..baf51286 100644 --- a/lara.lock +++ b/lara.lock @@ -93,6 +93,7 @@ files: command.settings.repairVault: cee560b1fd5ad9d1ff1c19a0a2afe77a command.settings.useLightMode: a76d5b1c076983bc113d247f0520c166 command.settings.useDarkMode: ba7873c42ae00542ba71db9be3b6761f + command.settings.useSystemTheme: 6d8c69d916d3df8ceed8dfc154196d07 command.settings.toggleGitignoredFilesVisibility: 0a2d3ea4e8ff8b00a801183caeb97a03 command.ai.openAgents: 612abe804edd0f5ae228a534f77f3d23 command.ai.restoreGuidance: 23331fecc692d11d85cf24838ed273f2 @@ -114,6 +115,7 @@ files: settings.theme.label: d721757161f7f70c5b0949fdb6ec2c30 settings.theme.light: 9914a0ce04a7b7b6a8e39bec55064b82 settings.theme.dark: a18366b217ebf811ad1886e4f4f865b2 + settings.theme.system: a45da96d0bf6575970f2d27af22be28a settings.language.title: 4994a8ffeba4ac3140beb89e8d41f174 settings.language.description: e2bb9b523067fdc32a494b1d6fd97906 settings.language.label: 244c7b77926f077b863c33ff1244e1ec diff --git a/src-tauri/src/settings.rs b/src-tauri/src/settings.rs index 744862d9..eedfc9b8 100644 --- a/src-tauri/src/settings.rs +++ b/src-tauri/src/settings.rs @@ -123,7 +123,7 @@ 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), + Some(mode) if mode == "light" || mode == "dark" || mode == "system" => Some(mode), _ => None, } } @@ -492,11 +492,20 @@ mod tests { } #[test] - fn test_invalid_theme_mode_is_filtered() { + fn test_system_theme_mode_is_preserved() { let loaded = save_and_reload(Settings { theme_mode: Some("system".to_string()), ..Default::default() }); + assert_eq!(loaded.theme_mode.as_deref(), Some("system")); + } + + #[test] + fn test_invalid_theme_mode_is_filtered() { + let loaded = save_and_reload(Settings { + theme_mode: Some("sepia".to_string()), + ..Default::default() + }); assert!(loaded.theme_mode.is_none()); } diff --git a/src/components/SettingsPanel.test.tsx b/src/components/SettingsPanel.test.tsx index 4e97e82e..7d0f346a 100644 --- a/src/components/SettingsPanel.test.tsx +++ b/src/components/SettingsPanel.test.tsx @@ -57,15 +57,50 @@ function createStorageMock(): Storage { } } +function installMatchMedia(matches = false) { + Object.defineProperty(window, 'matchMedia', { + configurable: true, + value: vi.fn((query: string) => ({ + matches, + media: query, + onchange: null, + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + addListener: vi.fn(), + removeListener: vi.fn(), + dispatchEvent: vi.fn(() => true), + })), + }) +} + describe('SettingsPanel', () => { const onSave = vi.fn() const onClose = vi.fn() const localStorageMock = createStorageMock() + function renderOpenSettings(settings: Settings = emptySettings) { + return render( + + ) + } + + function saveSettingsPanel() { + fireEvent.click(screen.getByTestId('settings-save')) + } + + function expectSettingsSaved(partial: Partial) { + expect(onSave).toHaveBeenCalledWith(expect.objectContaining(partial)) + } + + function selectThemeMode(label: string) { + fireEvent.click(screen.getByRole('radio', { name: label })) + } + beforeEach(() => { vi.clearAllMocks() trackEventMock.mockClear() Object.defineProperty(window, 'localStorage', { value: localStorageMock, configurable: true }) + installMatchMedia(false) window.localStorage.clear() document.documentElement.removeAttribute('data-theme') document.documentElement.classList.remove('dark') @@ -257,6 +292,7 @@ describe('SettingsPanel', () => { 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') + expect(screen.getByRole('radio', { name: 'System' })).toHaveAttribute('aria-checked', 'false') }) it('defaults the language selector to system language', () => { @@ -362,31 +398,42 @@ describe('SettingsPanel', () => { }) it('saves the selected dark color mode', () => { - render( - - ) + renderOpenSettings() - fireEvent.click(screen.getByRole('radio', { name: 'Dark' })) - fireEvent.click(screen.getByTestId('settings-save')) + selectThemeMode('Dark') + saveSettingsPanel() - expect(onSave).toHaveBeenCalledWith(expect.objectContaining({ + expectSettingsSaved({ theme_mode: 'dark', - })) + }) }) it('applies the selected dark color mode immediately while settings stays open', () => { - render( - - ) + renderOpenSettings() - fireEvent.click(screen.getByRole('radio', { name: 'Dark' })) + selectThemeMode('Dark') expect(document.documentElement).toHaveAttribute('data-theme', 'dark') expect(document.documentElement).toHaveClass('dark') expect(window.localStorage.getItem(THEME_MODE_STORAGE_KEY)).toBe('dark') - expect(onSave).toHaveBeenCalledWith(expect.objectContaining({ + expectSettingsSaved({ theme_mode: 'dark', - })) + }) + }) + + it('saves system color mode while applying the current OS appearance immediately', () => { + installMatchMedia(true) + renderOpenSettings() + + selectThemeMode('System') + saveSettingsPanel() + + expect(document.documentElement).toHaveAttribute('data-theme', 'dark') + expect(document.documentElement).toHaveClass('dark') + expect(window.localStorage.getItem(THEME_MODE_STORAGE_KEY)).toBe('system') + expectSettingsSaved({ + theme_mode: 'system', + }) }) it('preserves a saved dark color mode until changed', () => { diff --git a/src/components/SettingsPanel.tsx b/src/components/SettingsPanel.tsx index a00cca50..3c271f56 100644 --- a/src/components/SettingsPanel.tsx +++ b/src/components/SettingsPanel.tsx @@ -24,7 +24,7 @@ import { type RefObject, } from 'react' import { Moon, Sun, X } from '@phosphor-icons/react' -import { Bot, Copy, Folder, GitBranch, ListChecks, Palette, RefreshCw, ShieldCheck } from 'lucide-react' +import { Bot, Copy, Folder, GitBranch, ListChecks, Monitor, Palette, RefreshCw, ShieldCheck } from 'lucide-react' import type { Settings } from '../types' import { APP_LOCALES, @@ -37,7 +37,7 @@ import { type UiLanguagePreference, } from '../lib/i18n' import { - applyThemeModeToDocument, + applyThemeSelectionToDocument, DEFAULT_THEME_MODE, readStoredThemeMode, type ThemeMode, @@ -331,7 +331,8 @@ function sanitizePositiveInteger(value: number | null | undefined, fallback: num } function applyThemeModeSelection(value: ThemeMode): void { - if (typeof document !== 'undefined') applyThemeModeToDocument(document, value) + const matchMedia = typeof window !== 'undefined' ? window.matchMedia?.bind(window) : undefined + if (typeof document !== 'undefined') applyThemeSelectionToDocument(document, value, matchMedia) if (typeof window !== 'undefined') writeStoredThemeMode(window.localStorage, value) } @@ -879,6 +880,9 @@ function ThemeModeControl({ + + + ) } diff --git a/src/hooks/commands/localizeCommands.ts b/src/hooks/commands/localizeCommands.ts index 8674449a..7a0c0982 100644 --- a/src/hooks/commands/localizeCommands.ts +++ b/src/hooks/commands/localizeCommands.ts @@ -65,6 +65,7 @@ const STATIC_LABEL_KEYS: Partial> = { 'repair-vault': 'command.settings.repairVault', 'use-light-mode': 'command.settings.useLightMode', 'use-dark-mode': 'command.settings.useDarkMode', + 'use-system-theme-mode': 'command.settings.useSystemTheme', 'toggle-gitignored-files-visibility': 'command.settings.toggleGitignoredFilesVisibility', 'open-ai-agents': 'command.ai.openAgents', 'restore-vault-ai-guidance': 'command.ai.restoreGuidance', diff --git a/src/hooks/commands/settingsCommands.test.ts b/src/hooks/commands/settingsCommands.test.ts index 7360d418..ca90ff66 100644 --- a/src/hooks/commands/settingsCommands.test.ts +++ b/src/hooks/commands/settingsCommands.test.ts @@ -72,6 +72,7 @@ describe('buildSettingsCommands', () => { const lightMode = commands.find((item) => item.id === 'use-light-mode') const darkMode = commands.find((item) => item.id === 'use-dark-mode') + const systemMode = commands.find((item) => item.id === 'use-system-theme-mode') expect(lightMode).toMatchObject({ label: 'Use Light Mode', @@ -85,12 +86,20 @@ describe('buildSettingsCommands', () => { group: 'Settings', }) expect(darkMode?.keywords).toEqual(expect.arrayContaining(['theme', 'dark mode'])) + expect(systemMode).toMatchObject({ + label: 'Use System Theme', + enabled: true, + group: 'Settings', + }) + expect(systemMode?.keywords).toEqual(expect.arrayContaining(['theme', 'system theme'])) lightMode?.execute() darkMode?.execute() + systemMode?.execute() expect(onSetThemeMode).toHaveBeenNthCalledWith(1, 'light') expect(onSetThemeMode).toHaveBeenNthCalledWith(2, 'dark') + expect(onSetThemeMode).toHaveBeenNthCalledWith(3, 'system') }) it('keeps theme mode commands visible but disabled until settings can be saved', () => { @@ -104,6 +113,10 @@ describe('buildSettingsCommands', () => { label: 'Use Dark Mode', enabled: false, }) + expect(commands.find((item) => item.id === 'use-system-theme-mode')).toMatchObject({ + label: 'Use System Theme', + enabled: false, + }) }) it('localizes language commands', () => { @@ -125,6 +138,9 @@ describe('buildSettingsCommands', () => { expect(commands.find((item) => item.id === 'use-light-mode')).toMatchObject({ label: '使用浅色模式', }) + expect(commands.find((item) => item.id === 'use-system-theme-mode')).toMatchObject({ + label: '使用系统主题', + }) }) it('adds a create-empty-vault command when the handler is available', () => { diff --git a/src/hooks/commands/settingsCommands.ts b/src/hooks/commands/settingsCommands.ts index 13f002f6..0f092f95 100644 --- a/src/hooks/commands/settingsCommands.ts +++ b/src/hooks/commands/settingsCommands.ts @@ -147,6 +147,14 @@ function buildThemeCommands({ enabled: canSetThemeMode, execute: () => onSetThemeMode?.('dark'), }, + { + id: 'use-system-theme-mode', + label: t('command.settings.useSystemTheme'), + group: 'Settings', + keywords: ['theme', 'appearance', 'system', 'system theme', 'auto'], + enabled: canSetThemeMode, + execute: () => onSetThemeMode?.('system'), + }, ] } diff --git a/src/hooks/useCommandRegistry.test.ts b/src/hooks/useCommandRegistry.test.ts index 9e91e2e3..47f5c677 100644 --- a/src/hooks/useCommandRegistry.test.ts +++ b/src/hooks/useCommandRegistry.test.ts @@ -453,6 +453,7 @@ describe('useCommandRegistry', () => { const { result } = renderHook(() => useCommandRegistry(config)) const lightMode = findCommand(result.current, 'use-light-mode') const darkMode = findCommand(result.current, 'use-dark-mode') + const systemMode = findCommand(result.current, 'use-system-theme-mode') expect(lightMode).toMatchObject({ label: 'Use Light Mode', @@ -464,12 +465,19 @@ describe('useCommandRegistry', () => { enabled: true, group: 'Settings', }) + expect(systemMode).toMatchObject({ + label: 'Use System Theme', + enabled: true, + group: 'Settings', + }) lightMode?.execute() darkMode?.execute() + systemMode?.execute() expect(onSetThemeMode).toHaveBeenNthCalledWith(1, 'light') expect(onSetThemeMode).toHaveBeenNthCalledWith(2, 'dark') + expect(onSetThemeMode).toHaveBeenNthCalledWith(3, 'system') }) it('includes a New AI chat command that opens and resets the panel session', () => { diff --git a/src/hooks/useDocumentThemeMode.ts b/src/hooks/useDocumentThemeMode.ts index 22c468ca..937735c8 100644 --- a/src/hooks/useDocumentThemeMode.ts +++ b/src/hooks/useDocumentThemeMode.ts @@ -1,13 +1,13 @@ import { useSyncExternalStore } from 'react' import { DEFAULT_THEME_MODE, - normalizeThemeMode, - type ThemeMode, + normalizeResolvedThemeMode, + type ResolvedThemeMode, } from '../lib/themeMode' -function readDocumentThemeMode(): ThemeMode { +function readDocumentThemeMode(): ResolvedThemeMode { if (typeof document === 'undefined') return DEFAULT_THEME_MODE - return normalizeThemeMode(document.documentElement.getAttribute('data-theme')) ?? DEFAULT_THEME_MODE + return normalizeResolvedThemeMode(document.documentElement.getAttribute('data-theme')) ?? DEFAULT_THEME_MODE } function subscribeDocumentThemeMode(onChange: () => void): () => void { @@ -24,7 +24,7 @@ function subscribeDocumentThemeMode(onChange: () => void): () => void { return () => observer.disconnect() } -export function useDocumentThemeMode(): ThemeMode { +export function useDocumentThemeMode(): ResolvedThemeMode { return useSyncExternalStore( subscribeDocumentThemeMode, readDocumentThemeMode, diff --git a/src/hooks/useSettings.test.ts b/src/hooks/useSettings.test.ts index 968d0c33..9f88d506 100644 --- a/src/hooks/useSettings.test.ts +++ b/src/hooks/useSettings.test.ts @@ -8,6 +8,10 @@ import { } from '../lib/gitignoredVisibilityEvents' import { useSettings } from './useSettings' +const { trackEventMock } = vi.hoisted(() => ({ + trackEventMock: vi.fn(), +})) + const defaultSettings: Settings = { auto_pull_interval_minutes: null, autogit_enabled: null, @@ -78,6 +82,10 @@ vi.mock('../mock-tauri', () => ({ mockInvoke: (cmd: string, args?: Record) => mockInvokeFn(cmd, args), })) +vi.mock('../lib/telemetry', () => ({ + trackEvent: trackEventMock, +})) + async function renderLoadedSettings(): Promise { const { result } = renderHook(() => useSettings()) @@ -117,6 +125,7 @@ function changedSettings(): Settings { describe('useSettings', () => { beforeEach(() => { vi.clearAllMocks() + trackEventMock.mockClear() mockSettingsStore = { ...defaultSettings } nativeInvoke.mockResolvedValue(undefined) }) @@ -198,6 +207,23 @@ describe('useSettings', () => { expect(result.current.settings).toEqual(newSettings) }) + it('tracks theme mode changes after settings save succeeds', async () => { + const { result } = renderHook(() => useSettings()) + + await waitFor(() => { + expect(result.current.loaded).toBe(true) + }) + + await act(async () => { + await result.current.saveSettings({ + ...defaultSettings, + theme_mode: 'system', + }) + }) + + expect(trackEventMock).toHaveBeenCalledWith('theme_mode_changed', { mode: 'system' }) + }) + it('preserves the Gitignored files visibility preference', async () => { mockSettingsStore = { ...savedSettings, diff --git a/src/hooks/useSettings.ts b/src/hooks/useSettings.ts index 6b30c72c..c1974a03 100644 --- a/src/hooks/useSettings.ts +++ b/src/hooks/useSettings.ts @@ -9,8 +9,9 @@ import { TOGGLE_GITIGNORED_VISIBILITY_EVENT, } from '../lib/gitignoredVisibilityEvents' import { serializeUiLanguagePreference } from '../lib/i18n' +import { trackThemeModeChanged } from '../lib/productAnalytics' import { normalizeReleaseChannel, serializeReleaseChannel } from '../lib/releaseChannel' -import { normalizeThemeMode } from '../lib/themeMode' +import { DEFAULT_THEME_MODE, normalizeThemeMode, type ThemeMode } from '../lib/themeMode' import type { Settings } from '../types' import { normalizeNoteWidthMode } from '../utils/noteWidth' @@ -78,6 +79,10 @@ function normalizeSettings(settings: Settings): Settings { } } +function effectiveThemeMode(settings: Settings): ThemeMode { + return normalizeThemeMode(settings.theme_mode) ?? DEFAULT_THEME_MODE +} + export function useSettings() { const [settings, setSettings] = useState(EMPTY_SETTINGS) const [loaded, setLoaded] = useState(false) @@ -99,10 +104,15 @@ export function useSettings() { const saveSettings = useCallback(async (newSettings: Settings) => { const previousHideGitignored = shouldHideGitignoredFiles(settings) + const previousThemeMode = effectiveThemeMode(settings) const normalizedSettings = normalizeSettings(newSettings) try { await tauriCall('save_settings', { settings: normalizedSettings }) setSettings(normalizedSettings) + const nextThemeMode = effectiveThemeMode(normalizedSettings) + if (previousThemeMode !== nextThemeMode) { + trackThemeModeChanged(nextThemeMode) + } const nextHideGitignored = shouldHideGitignoredFiles(normalizedSettings) if (previousHideGitignored !== nextHideGitignored) { notifyGitignoredVisibilityChanged(nextHideGitignored) diff --git a/src/hooks/useThemeMode.test.ts b/src/hooks/useThemeMode.test.ts index 0bb67615..2c34133c 100644 --- a/src/hooks/useThemeMode.test.ts +++ b/src/hooks/useThemeMode.test.ts @@ -1,4 +1,4 @@ -import { renderHook } from '@testing-library/react' +import { act, 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' @@ -20,6 +20,7 @@ describe('useThemeMode', () => { beforeEach(() => { Object.defineProperty(window, 'localStorage', { value: localStorageMock, configurable: true }) + installMatchMedia(false) document.documentElement.removeAttribute('data-theme') document.documentElement.classList.remove('dark') window.localStorage.clear() @@ -46,4 +47,67 @@ describe('useThemeMode', () => { expect(document.documentElement).toHaveAttribute('data-theme', 'dark') }) + + it('keeps system stored while applying the resolved OS appearance', () => { + installMatchMedia(true) + + renderHook(() => useThemeMode('system', true)) + + expect(document.documentElement).toHaveAttribute('data-theme', 'dark') + expect(document.documentElement).toHaveClass('dark') + expect(window.localStorage.getItem(THEME_MODE_STORAGE_KEY)).toBe('system') + }) + + it('updates the document theme when the OS appearance changes in system mode', () => { + const media = installMatchMedia(true) + + const { unmount } = renderHook(() => useThemeMode('system', true)) + + act(() => media.setMatches(false)) + expect(document.documentElement).toHaveAttribute('data-theme', 'light') + expect(document.documentElement).not.toHaveClass('dark') + + unmount() + act(() => media.setMatches(true)) + expect(document.documentElement).toHaveAttribute('data-theme', 'light') + }) }) + +function installMatchMedia(initialMatches: boolean) { + let matches = initialMatches + const listeners = new Set<(event: MediaQueryListEvent) => void>() + const media = '(prefers-color-scheme: dark)' + const mediaQueryList: MediaQueryList = { + get matches() { return matches }, + media, + onchange: null, + addEventListener: (_type, listener) => { + if (_type !== 'change' || typeof listener !== 'function') return + listeners.add(listener) + }, + removeEventListener: (_type, listener) => { + if (_type !== 'change' || typeof listener !== 'function') return + listeners.delete(listener) + }, + addListener: (listener) => listeners.add(listener), + removeListener: (listener) => listeners.delete(listener), + dispatchEvent: () => true, + } + + Object.defineProperty(window, 'matchMedia', { + configurable: true, + value: vi.fn(() => mediaQueryList), + }) + + return { + setMatches(nextMatches: boolean) { + matches = nextMatches + const event = new Event('change') as MediaQueryListEvent + Object.defineProperties(event, { + matches: { value: nextMatches }, + media: { value: media }, + }) + for (const listener of listeners) listener(event) + }, + } +} diff --git a/src/hooks/useThemeMode.ts b/src/hooks/useThemeMode.ts index 2a7137bd..39e003c2 100644 --- a/src/hooks/useThemeMode.ts +++ b/src/hooks/useThemeMode.ts @@ -1,8 +1,9 @@ import { useEffect } from 'react' import { - applyThemeModeToDocument, + applyThemeSelectionToDocument, DEFAULT_THEME_MODE, readStoredThemeMode, + SYSTEM_THEME_MEDIA_QUERY, writeStoredThemeMode, type ThemeMode, } from '../lib/themeMode' @@ -13,6 +14,45 @@ function resolveRuntimeThemeMode(themeMode: ThemeMode | null | undefined): Theme return readStoredThemeMode(window.localStorage) ?? DEFAULT_THEME_MODE } +function currentMatchMedia(): Window['matchMedia'] | undefined { + return typeof window !== 'undefined' && typeof window.matchMedia === 'function' + ? window.matchMedia.bind(window) + : undefined +} + +function writeThemeModeMirror(themeMode: ThemeMode): void { + if (typeof window === 'undefined') return + writeStoredThemeMode(window.localStorage, themeMode) +} + +function applySelectedThemeMode(themeMode: ThemeMode): void { + applyThemeSelectionToDocument(document, themeMode, currentMatchMedia()) + writeThemeModeMirror(themeMode) +} + +function getSystemThemeMediaQueryList(): MediaQueryList | null { + const matchMedia = currentMatchMedia() + if (!matchMedia) return null + + try { + return matchMedia(SYSTEM_THEME_MEDIA_QUERY) + } catch { + return null + } +} + +function subscribeSystemThemeChanges(mediaQueryList: MediaQueryList): () => void { + const handleSystemThemeChange = () => applySelectedThemeMode('system') + + if (typeof mediaQueryList.addEventListener === 'function') { + mediaQueryList.addEventListener('change', handleSystemThemeChange) + return () => mediaQueryList.removeEventListener('change', handleSystemThemeChange) + } + + mediaQueryList.addListener(handleSystemThemeChange) + return () => mediaQueryList.removeListener(handleSystemThemeChange) +} + export function useThemeMode( themeMode: ThemeMode | null | undefined, loaded: boolean, @@ -20,11 +60,11 @@ export function useThemeMode( useEffect(() => { if (!loaded || typeof document === 'undefined') return - const resolvedMode = resolveRuntimeThemeMode(themeMode) - applyThemeModeToDocument(document, resolvedMode) + const selectedMode = resolveRuntimeThemeMode(themeMode) + applySelectedThemeMode(selectedMode) - if (typeof window !== 'undefined') { - writeStoredThemeMode(window.localStorage, resolvedMode) - } + if (selectedMode !== 'system') return + const mediaQueryList = getSystemThemeMediaQueryList() + return mediaQueryList ? subscribeSystemThemeChanges(mediaQueryList) : undefined }, [loaded, themeMode]) } diff --git a/src/lib/locales/de-DE.json b/src/lib/locales/de-DE.json index db48f88e..3e614bf1 100644 --- a/src/lib/locales/de-DE.json +++ b/src/lib/locales/de-DE.json @@ -91,6 +91,7 @@ "command.settings.repairVault": "Vault reparieren", "command.settings.useLightMode": "Hellen Modus verwenden", "command.settings.useDarkMode": "Dunklen Modus verwenden", + "command.settings.useSystemTheme": "Systemdesign verwenden", "command.settings.toggleGitignoredFilesVisibility": "Sichtbarkeit von Gitignored-Dateien umschalten", "command.ai.openAgents": "KI-Agents öffnen", "command.ai.restoreGuidance": "Tolaria-KI-Anleitung wiederherstellen", @@ -112,6 +113,7 @@ "settings.theme.label": "Design", "settings.theme.light": "Hell", "settings.theme.dark": "Dunkel", + "settings.theme.system": "System", "settings.language.title": "Sprache", "settings.language.description": "Wählen Sie die Anzeigesprache für Tolaria Chrome aus. Das System folgt macOS, wenn diese Sprache unterstützt wird, wobei Englisch als Ausweichsprache dient.", "settings.language.label": "Anzeigesprache", diff --git a/src/lib/locales/en.json b/src/lib/locales/en.json index 98de22be..da886f38 100644 --- a/src/lib/locales/en.json +++ b/src/lib/locales/en.json @@ -91,6 +91,7 @@ "command.settings.repairVault": "Repair Vault", "command.settings.useLightMode": "Use Light Mode", "command.settings.useDarkMode": "Use Dark Mode", + "command.settings.useSystemTheme": "Use System Theme", "command.settings.toggleGitignoredFilesVisibility": "Toggle Gitignored Files Visibility", "command.ai.openAgents": "Open AI Agents", "command.ai.restoreGuidance": "Restore Tolaria AI Guidance", @@ -112,6 +113,7 @@ "settings.theme.label": "Theme", "settings.theme.light": "Light", "settings.theme.dark": "Dark", + "settings.theme.system": "System", "settings.language.title": "Language", "settings.language.description": "Choose the display language for Tolaria chrome. System follows macOS when that language is supported, with English as the fallback.", "settings.language.label": "Display language", diff --git a/src/lib/locales/es-419.json b/src/lib/locales/es-419.json index 1fa5a276..5e21cdb4 100644 --- a/src/lib/locales/es-419.json +++ b/src/lib/locales/es-419.json @@ -91,6 +91,7 @@ "command.settings.repairVault": "Reparar el repositorio", "command.settings.useLightMode": "Usar modo claro", "command.settings.useDarkMode": "Usar modo oscuro", + "command.settings.useSystemTheme": "Usar el tema del sistema", "command.settings.toggleGitignoredFilesVisibility": "Activar/desactivar la visibilidad de los archivos ignorados por Git", "command.ai.openAgents": "Abrir agentes de IA", "command.ai.restoreGuidance": "Restaurar la guía de IA de Tolaria", @@ -112,6 +113,7 @@ "settings.theme.label": "Tema", "settings.theme.light": "Claro", "settings.theme.dark": "Oscuro", + "settings.theme.system": "Sistema", "settings.language.title": "Idioma", "settings.language.description": "Elija el idioma de visualización para Tolaria Chrome. El sistema sigue la configuración de macOS cuando ese idioma es compatible; de lo contrario, se utiliza el inglés.", "settings.language.label": "Idioma de la interfaz", diff --git a/src/lib/locales/es-ES.json b/src/lib/locales/es-ES.json index 79ffcfa0..68e3f363 100644 --- a/src/lib/locales/es-ES.json +++ b/src/lib/locales/es-ES.json @@ -91,6 +91,7 @@ "command.settings.repairVault": "Reparar el almacén", "command.settings.useLightMode": "Usar modo claro", "command.settings.useDarkMode": "Usar modo oscuro", + "command.settings.useSystemTheme": "Usar el tema del sistema", "command.settings.toggleGitignoredFilesVisibility": "Activar/desactivar la visibilidad de los archivos gitignored", "command.ai.openAgents": "Abrir agentes de IA", "command.ai.restoreGuidance": "Restaurar la guía de IA de Tolaria", @@ -112,6 +113,7 @@ "settings.theme.label": "Tema", "settings.theme.light": "Claro", "settings.theme.dark": "Oscuro", + "settings.theme.system": "Sistema", "settings.language.title": "Idioma", "settings.language.description": "Elija el idioma de visualización de Tolaria Chrome. El sistema sigue la configuración de macOS cuando ese idioma es compatible; en caso contrario, se utiliza el inglés.", "settings.language.label": "Idioma de la interfaz", diff --git a/src/lib/locales/fr-FR.json b/src/lib/locales/fr-FR.json index e4c282a5..5bff9cb8 100644 --- a/src/lib/locales/fr-FR.json +++ b/src/lib/locales/fr-FR.json @@ -91,6 +91,7 @@ "command.settings.repairVault": "Réparer le coffre-fort", "command.settings.useLightMode": "Utiliser le mode clair", "command.settings.useDarkMode": "Utiliser le mode sombre", + "command.settings.useSystemTheme": "Utiliser le thème système", "command.settings.toggleGitignoredFilesVisibility": "Activer/désactiver la visibilité des fichiers Gitignored", "command.ai.openAgents": "Ouvrir les agents d'IA", "command.ai.restoreGuidance": "Restaurer les conseils de l'IA Tolaria", @@ -112,6 +113,7 @@ "settings.theme.label": "Thème", "settings.theme.light": "Clair", "settings.theme.dark": "Sombre", + "settings.theme.system": "Système", "settings.language.title": "Langue", "settings.language.description": "Choisissez la langue d'affichage de Tolaria Chrome. Le système suit macOS lorsque cette langue est prise en charge, l'anglais étant la langue de secours.", "settings.language.label": "Langue d'affichage", diff --git a/src/lib/locales/it-IT.json b/src/lib/locales/it-IT.json index 78fa8fad..e1655f34 100644 --- a/src/lib/locales/it-IT.json +++ b/src/lib/locales/it-IT.json @@ -91,6 +91,7 @@ "command.settings.repairVault": "Ripara Vault", "command.settings.useLightMode": "Usa modalità chiara", "command.settings.useDarkMode": "Usa modalità scura", + "command.settings.useSystemTheme": "Usa il tema di sistema", "command.settings.toggleGitignoredFilesVisibility": "Attiva/disattiva la visibilità dei file Gitignored", "command.ai.openAgents": "Apri agenti IA", "command.ai.restoreGuidance": "Ripristina la guida IA di Tolaria", @@ -112,6 +113,7 @@ "settings.theme.label": "Tema", "settings.theme.light": "Chiaro", "settings.theme.dark": "Scuro", + "settings.theme.system": "Sistema", "settings.language.title": "Lingua", "settings.language.description": "Scegli la lingua di visualizzazione per Tolaria chrome. Il sistema segue le impostazioni di macOS quando la lingua in questione è supportata; in caso contrario, viene utilizzata l'inglese come lingua predefinita.", "settings.language.label": "Lingua di visualizzazione", diff --git a/src/lib/locales/ja-JP.json b/src/lib/locales/ja-JP.json index 3eb2d05d..06d20119 100644 --- a/src/lib/locales/ja-JP.json +++ b/src/lib/locales/ja-JP.json @@ -91,6 +91,7 @@ "command.settings.repairVault": "Vault を修復", "command.settings.useLightMode": "ライトモードを使用", "command.settings.useDarkMode": "ダークモードを使用", + "command.settings.useSystemTheme": "システムテーマを使用", "command.settings.toggleGitignoredFilesVisibility": "Gitignoredファイルの表示/非表示を切り替える", "command.ai.openAgents": "AIエージェントを開く", "command.ai.restoreGuidance": "Tolaria AIガイダンスを復元", @@ -112,6 +113,7 @@ "settings.theme.label": "テーマ", "settings.theme.light": "ライト", "settings.theme.dark": "ダーク", + "settings.theme.system": "システム", "settings.language.title": "言語", "settings.language.description": "Tolaria Chromeの表示言語を選択します。システムは、対応言語がサポートされている場合はmacOSに従い、サポートされていない場合は英語が使用されます。", "settings.language.label": "表示言語", diff --git a/src/lib/locales/ko-KR.json b/src/lib/locales/ko-KR.json index 3c0ea19d..83d95713 100644 --- a/src/lib/locales/ko-KR.json +++ b/src/lib/locales/ko-KR.json @@ -91,6 +91,7 @@ "command.settings.repairVault": "Vault 복구", "command.settings.useLightMode": "라이트 모드 사용", "command.settings.useDarkMode": "다크 모드 사용", + "command.settings.useSystemTheme": "시스템 테마 사용", "command.settings.toggleGitignoredFilesVisibility": "Gitignored 파일 표시/숨기기", "command.ai.openAgents": "AI 에이전트 열기", "command.ai.restoreGuidance": "Tolaria AI 가이던스 복원", @@ -112,6 +113,7 @@ "settings.theme.label": "테마", "settings.theme.light": "라이트", "settings.theme.dark": "Dark", + "settings.theme.system": "시스템", "settings.language.title": "언어", "settings.language.description": "Tolaria chrome의 표시 언어를 선택하세요. 해당 언어가 지원되는 경우 시스템은 macOS를 따릅니다. 지원되지 않는 경우 영어가 기본 언어로 설정됩니다.", "settings.language.label": "표시 언어", diff --git a/src/lib/locales/pl-PL.json b/src/lib/locales/pl-PL.json index 9889eff7..e3765322 100644 --- a/src/lib/locales/pl-PL.json +++ b/src/lib/locales/pl-PL.json @@ -91,6 +91,7 @@ "command.settings.repairVault": "Napraw sejf", "command.settings.useLightMode": "Użyj trybu jasnego", "command.settings.useDarkMode": "Użyj trybu ciemnego", + "command.settings.useSystemTheme": "Użyj motywu systemowego", "command.settings.toggleGitignoredFilesVisibility": "Przełącz widoczność plików ignorowanych przez Git", "command.ai.openAgents": "Otwórz agentów AI", "command.ai.restoreGuidance": "Przywróć wytyczne Tolaria AI", @@ -112,6 +113,7 @@ "settings.theme.label": "Motyw", "settings.theme.light": "Jasny", "settings.theme.dark": "Ciemny", + "settings.theme.system": "System", "settings.language.title": "Język", "settings.language.description": "Wybierz język wyświetlania interfejsu Tolaria. Systemowy używa języka macOS, gdy jest obsługiwany, z angielskim jako językiem zapasowym.", "settings.language.label": "Język wyświetlania", diff --git a/src/lib/locales/pt-BR.json b/src/lib/locales/pt-BR.json index ef0013ab..69e1763b 100644 --- a/src/lib/locales/pt-BR.json +++ b/src/lib/locales/pt-BR.json @@ -91,6 +91,7 @@ "command.settings.repairVault": "Reparar cofre", "command.settings.useLightMode": "Usar modo claro", "command.settings.useDarkMode": "Usar modo escuro", + "command.settings.useSystemTheme": "Usar tema do sistema", "command.settings.toggleGitignoredFilesVisibility": "Alternar visibilidade dos arquivos ignorados pelo Git", "command.ai.openAgents": "Abrir agentes de IA", "command.ai.restoreGuidance": "Restaurar orientação de IA da Tolaria", @@ -112,6 +113,7 @@ "settings.theme.label": "Tema", "settings.theme.light": "Claro", "settings.theme.dark": "Escuro", + "settings.theme.system": "Sistema", "settings.language.title": "Idioma", "settings.language.description": "Escolha o idioma de exibição do Tolaria Chrome. O sistema segue o macOS quando esse idioma é suportado, com o inglês como opção alternativa.", "settings.language.label": "Idioma de exibição", diff --git a/src/lib/locales/pt-PT.json b/src/lib/locales/pt-PT.json index 13ec5e61..2315cddc 100644 --- a/src/lib/locales/pt-PT.json +++ b/src/lib/locales/pt-PT.json @@ -91,6 +91,7 @@ "command.settings.repairVault": "Reparar cofre", "command.settings.useLightMode": "Usar modo claro", "command.settings.useDarkMode": "Usar modo escuro", + "command.settings.useSystemTheme": "Utilizar o tema do sistema", "command.settings.toggleGitignoredFilesVisibility": "Alternar a visibilidade dos ficheiros Gitignored", "command.ai.openAgents": "Abrir agentes de IA", "command.ai.restoreGuidance": "Repor a orientação de IA da Tolaria", @@ -112,6 +113,7 @@ "settings.theme.label": "Tema", "settings.theme.light": "Claro", "settings.theme.dark": "Escuro", + "settings.theme.system": "Sistema", "settings.language.title": "Idioma", "settings.language.description": "Escolha o idioma de apresentação do Tolaria Chrome. O sistema segue o macOS quando esse idioma é suportado, sendo o inglês a opção de recurso.", "settings.language.label": "Idioma de apresentação", diff --git a/src/lib/locales/ru-RU.json b/src/lib/locales/ru-RU.json index 87e3bcd0..0c3d906a 100644 --- a/src/lib/locales/ru-RU.json +++ b/src/lib/locales/ru-RU.json @@ -91,6 +91,7 @@ "command.settings.repairVault": "Восстановить хранилище", "command.settings.useLightMode": "Использовать светлый режим", "command.settings.useDarkMode": "Использовать темный режим", + "command.settings.useSystemTheme": "Использовать системную тему", "command.settings.toggleGitignoredFilesVisibility": "Переключить отображение файлов, игнорируемых Git", "command.ai.openAgents": "Открыть AI Agents", "command.ai.restoreGuidance": "Восстановить руководство Tolaria AI", @@ -112,6 +113,7 @@ "settings.theme.label": "Тема", "settings.theme.light": "Светлая", "settings.theme.dark": "Темная", + "settings.theme.system": "Система", "settings.language.title": "Язык", "settings.language.description": "Выберите язык отображения для Tolaria chrome. Система использует язык macOS, если он поддерживается, а в противном случае — английский.", "settings.language.label": "Язык интерфейса", diff --git a/src/lib/locales/vi.json b/src/lib/locales/vi.json index d1e9be2f..7adb26ff 100644 --- a/src/lib/locales/vi.json +++ b/src/lib/locales/vi.json @@ -91,6 +91,7 @@ "command.settings.repairVault": "Sửa kho", "command.settings.useLightMode": "Dùng giao diện sáng", "command.settings.useDarkMode": "Dùng giao diện tối", + "command.settings.useSystemTheme": "Sử dụng Giao diện Hệ thống", "command.settings.toggleGitignoredFilesVisibility": "Bật/tắt hiển thị tệp Git Ignored", "command.ai.openAgents": "Mở tác nhân AI", "command.ai.restoreGuidance": "Khôi phục hướng dẫn AI của Tolaria", @@ -112,6 +113,7 @@ "settings.theme.label": "Chủ đề", "settings.theme.light": "Sáng", "settings.theme.dark": "Tối", + "settings.theme.system": "Hệ thống", "settings.language.title": "Ngôn ngữ", "settings.language.description": "Chọn ngôn ngữ hiển thị cho phần khung Tolaria. Hệ thống sẽ theo macOS nếu ngôn ngữ đó được hỗ trợ, còn tiếng Anh là phương án dự phòng.", "settings.language.label": "Ngôn ngữ hiển thị", diff --git a/src/lib/locales/zh-CN.json b/src/lib/locales/zh-CN.json index 91838a1c..b3ee21d7 100644 --- a/src/lib/locales/zh-CN.json +++ b/src/lib/locales/zh-CN.json @@ -91,6 +91,7 @@ "command.settings.repairVault": "修复仓库", "command.settings.useLightMode": "使用浅色模式", "command.settings.useDarkMode": "使用深色模式", + "command.settings.useSystemTheme": "使用系统主题", "command.settings.toggleGitignoredFilesVisibility": "切换 Gitignored 文件的可见性", "command.ai.openAgents": "打开 AI 代理设置", "command.ai.restoreGuidance": "恢复 Tolaria AI 指导文件", @@ -112,6 +113,7 @@ "settings.theme.label": "主题", "settings.theme.light": "浅色", "settings.theme.dark": "深色", + "settings.theme.system": "系统", "settings.language.title": "语言", "settings.language.description": "选择 Tolaria 界面的显示语言。系统选项会在支持时跟随 macOS,否则回退到英文。", "settings.language.label": "显示语言", diff --git a/src/lib/locales/zh-TW.json b/src/lib/locales/zh-TW.json index bd1c4b6c..5ed2b1d3 100644 --- a/src/lib/locales/zh-TW.json +++ b/src/lib/locales/zh-TW.json @@ -91,6 +91,7 @@ "command.settings.repairVault": "修復倉庫", "command.settings.useLightMode": "使用淺色模式", "command.settings.useDarkMode": "使用深色模式", + "command.settings.useSystemTheme": "使用系統主題", "command.settings.toggleGitignoredFilesVisibility": "切換 Gitignored 檔案的顯示狀態", "command.ai.openAgents": "開啟 AI 代理設定", "command.ai.restoreGuidance": "恢復 Tolaria AI 指導檔案", @@ -112,6 +113,7 @@ "settings.theme.label": "主題", "settings.theme.light": "淺色", "settings.theme.dark": "深色", + "settings.theme.system": "系統", "settings.language.title": "語言", "settings.language.description": "選擇 Tolaria 介面的顯示語言。系統選項會在支援時跟隨 macOS,否則回退到英文。", "settings.language.label": "顯示語言", diff --git a/src/lib/productAnalytics.ts b/src/lib/productAnalytics.ts index 839792c5..f6b63807 100644 --- a/src/lib/productAnalytics.ts +++ b/src/lib/productAnalytics.ts @@ -4,6 +4,7 @@ import { trackEvent } from './telemetry' import type { AllNotesFileVisibility } from '../utils/allNotesFileVisibility' import type { FilePreviewKind } from '../utils/filePreview' import type { NoteWidthMode } from '../types' +import type { ThemeMode } from './themeMode' type TrackedPreviewKind = FilePreviewKind | 'unsupported' type FilePreviewAction = 'copy_path' | 'open_external' | 'reveal' @@ -63,6 +64,10 @@ export function trackSidebarTypePluralizationChanged(enabled: boolean): void { }) } +export function trackThemeModeChanged(mode: ThemeMode): void { + trackEvent('theme_mode_changed', { mode }) +} + export function trackInlineImageLightboxOpened(): void { trackEvent('inline_image_lightbox_opened') } diff --git a/src/lib/themeMode.test.ts b/src/lib/themeMode.test.ts index 49edb9cb..8508a5b6 100644 --- a/src/lib/themeMode.test.ts +++ b/src/lib/themeMode.test.ts @@ -26,17 +26,19 @@ 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') + expect(normalizeThemeMode('system')).toBe('system') + expect(resolveThemeMode('system', makeMatchMedia(true))).toBe('dark') + expect(resolveThemeMode('system', makeMatchMedia(false))).toBe('light') + expect(resolveThemeMode('sepia')).toBe('light') }) it('reads and writes the current storage key', () => { const storage = makeStorage() - writeStoredThemeMode(storage, 'dark') + writeStoredThemeMode(storage, 'system') - expect(readStoredThemeMode(storage)).toBe('dark') - expect(storage.setItem).toHaveBeenCalledWith(THEME_MODE_STORAGE_KEY, 'dark') + expect(readStoredThemeMode(storage)).toBe('system') + expect(storage.setItem).toHaveBeenCalledWith(THEME_MODE_STORAGE_KEY, 'system') }) it('migrates the legacy storage key', () => { @@ -63,4 +65,25 @@ describe('themeMode', () => { expect(document.documentElement).toHaveAttribute('data-theme', 'dark') expect(document.documentElement).toHaveClass('dark') }) + + it('bootstraps system mode to the current OS appearance without storing system in data-theme', () => { + const storage = makeStorage({ [THEME_MODE_STORAGE_KEY]: 'system' }) + + expect(applyStoredThemeMode(document, storage, makeMatchMedia(true))).toBe('dark') + expect(document.documentElement).toHaveAttribute('data-theme', 'dark') + expect(document.documentElement).toHaveClass('dark') + }) }) + +function makeMatchMedia(matches: boolean): Window['matchMedia'] { + return ((query: string) => ({ + matches, + media: query, + onchange: null, + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + addListener: vi.fn(), + removeListener: vi.fn(), + dispatchEvent: vi.fn(() => true), + })) as Window['matchMedia'] +} diff --git a/src/lib/themeMode.ts b/src/lib/themeMode.ts index f4c6557b..87627c7a 100644 --- a/src/lib/themeMode.ts +++ b/src/lib/themeMode.ts @@ -3,20 +3,49 @@ import { APP_STORAGE_KEYS, LEGACY_APP_STORAGE_KEYS } from '../constants/appStora 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' +export const SYSTEM_THEME_MODE = 'system' +export const SYSTEM_THEME_MEDIA_QUERY = '(prefers-color-scheme: dark)' -const THEME_MODES = new Set(['light', 'dark']) +const RESOLVED_THEME_MODES = new Set(['light', 'dark']) +const THEME_MODES = new Set([...RESOLVED_THEME_MODES, SYSTEM_THEME_MODE]) -export type ThemeMode = 'light' | 'dark' +export type ResolvedThemeMode = 'light' | 'dark' +export type ThemeMode = ResolvedThemeMode | typeof SYSTEM_THEME_MODE type ThemeStorage = Pick type ThemeDocument = Pick +type ThemeMatchMedia = Window['matchMedia'] 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 +export function normalizeResolvedThemeMode(value: unknown): ResolvedThemeMode | null { + const mode = normalizeThemeMode(value) + return mode === 'light' || mode === 'dark' ? mode : null +} + +function resolveMatchMedia(matchMedia?: ThemeMatchMedia): ThemeMatchMedia | null { + if (matchMedia) return matchMedia + if (typeof window === 'undefined' || typeof window.matchMedia !== 'function') return null + return window.matchMedia.bind(window) +} + +export function resolveSystemThemeMode(matchMedia?: ThemeMatchMedia): ResolvedThemeMode { + const resolvedMatchMedia = resolveMatchMedia(matchMedia) + if (!resolvedMatchMedia) return DEFAULT_THEME_MODE + + try { + return resolvedMatchMedia(SYSTEM_THEME_MEDIA_QUERY).matches ? 'dark' : 'light' + } catch { + return DEFAULT_THEME_MODE + } +} + +export function resolveThemeMode(value: unknown, matchMedia?: ThemeMatchMedia): ResolvedThemeMode { + const mode = normalizeThemeMode(value) + if (mode === SYSTEM_THEME_MODE) return resolveSystemThemeMode(matchMedia) + return mode ?? DEFAULT_THEME_MODE } function safeGetThemeMode(storage: ThemeStorage, key: string): ThemeMode | null { @@ -50,17 +79,27 @@ export function writeStoredThemeMode(storage: ThemeStorage, mode: ThemeMode): vo safeSetThemeMode(storage, THEME_MODE_STORAGE_KEY, mode) } -export function applyThemeModeToDocument(documentObject: ThemeDocument, mode: ThemeMode): void { +export function applyThemeModeToDocument(documentObject: ThemeDocument, mode: ResolvedThemeMode): void { const root = documentObject.documentElement root.setAttribute('data-theme', mode) root.classList.toggle('dark', mode === 'dark') } +export function applyThemeSelectionToDocument( + documentObject: ThemeDocument, + mode: ThemeMode, + matchMedia?: ThemeMatchMedia, +): ResolvedThemeMode { + const resolvedMode = resolveThemeMode(mode, matchMedia) + applyThemeModeToDocument(documentObject, resolvedMode) + return resolvedMode +} + export function applyStoredThemeMode( documentObject: ThemeDocument, storage: ThemeStorage, -): ThemeMode { + matchMedia?: ThemeMatchMedia, +): ResolvedThemeMode { const mode = readStoredThemeMode(storage) ?? DEFAULT_THEME_MODE - applyThemeModeToDocument(documentObject, mode) - return mode + return applyThemeSelectionToDocument(documentObject, mode, matchMedia) }