feat(settings): follow system theme
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
41
docs/adr/0112-system-theme-mode.md
Normal file
41
docs/adr/0112-system-theme-mode.md
Normal 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.
|
||||
@@ -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 |
|
||||
|
||||
16
index.html
16
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');
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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'),
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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])
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "表示言語",
|
||||
|
||||
@@ -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": "표시 언어",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "Язык интерфейса",
|
||||
|
||||
@@ -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ị",
|
||||
|
||||
@@ -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": "显示语言",
|
||||
|
||||
@@ -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": "顯示語言",
|
||||
|
||||
@@ -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')
|
||||
}
|
||||
|
||||
@@ -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']
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user