feat(settings): follow system theme

This commit is contained in:
lucaronin
2026-05-05 15:00:24 +02:00
parent d5ecd567d0
commit 520b2c9799
37 changed files with 441 additions and 55 deletions

View File

@@ -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

View File

@@ -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

View File

@@ -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.

View File

@@ -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.

View File

@@ -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 |

View File

@@ -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');
}

View File

@@ -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

View File

@@ -123,7 +123,7 @@ pub fn normalize_default_ai_agent(value: Option<&str>) -> Option<String> {
pub fn normalize_theme_mode(value: Option<&str>) -> Option<String> {
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());
}

View File

@@ -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(
<SettingsPanel open={true} settings={settings} onSave={onSave} onClose={onClose} />
)
}
function saveSettingsPanel() {
fireEvent.click(screen.getByTestId('settings-save'))
}
function expectSettingsSaved(partial: Partial<Settings>) {
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(
<SettingsPanel open={true} settings={emptySettings} onSave={onSave} onClose={onClose} />
)
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(
<SettingsPanel open={true} settings={emptySettings} onSave={onSave} onClose={onClose} />
)
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', () => {

View File

@@ -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({
<ThemeModeButton label={t('settings.theme.dark')} selected={value === 'dark'} value="dark" onSelect={onChange}>
<Moon size={14} />
</ThemeModeButton>
<ThemeModeButton label={t('settings.theme.system')} selected={value === 'system'} value="system" onSelect={onChange}>
<Monitor size={14} />
</ThemeModeButton>
</div>
)
}

View File

@@ -65,6 +65,7 @@ const STATIC_LABEL_KEYS: Partial<Record<string, TranslationKey>> = {
'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',

View File

@@ -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', () => {

View File

@@ -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'),
},
]
}

View File

@@ -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', () => {

View File

@@ -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,

View File

@@ -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<string, unknown>) => mockInvokeFn(cmd, args),
}))
vi.mock('../lib/telemetry', () => ({
trackEvent: trackEventMock,
}))
async function renderLoadedSettings(): Promise<Settings> {
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,

View File

@@ -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<Settings>(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<null>('save_settings', { settings: normalizedSettings })
setSettings(normalizedSettings)
const nextThemeMode = effectiveThemeMode(normalizedSettings)
if (previousThemeMode !== nextThemeMode) {
trackThemeModeChanged(nextThemeMode)
}
const nextHideGitignored = shouldHideGitignoredFiles(normalizedSettings)
if (previousHideGitignored !== nextHideGitignored) {
notifyGitignoredVisibilityChanged(nextHideGitignored)

View File

@@ -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)
},
}
}

View File

@@ -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])
}

View File

@@ -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",

View File

@@ -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",

View File

@@ -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",

View File

@@ -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",

View File

@@ -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",

View File

@@ -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",

View File

@@ -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": "表示言語",

View File

@@ -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": "표시 언어",

View File

@@ -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",

View File

@@ -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",

View File

@@ -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",

View File

@@ -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": "Язык интерфейса",

View File

@@ -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ị",

View File

@@ -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": "显示语言",

View File

@@ -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": "顯示語言",

View File

@@ -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')
}

View File

@@ -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']
}

View File

@@ -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<Storage, 'getItem' | 'setItem'>
type ThemeDocument = Pick<Document, 'documentElement'>
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)
}