diff --git a/public/locales/en.json b/public/locales/en.json
index e10ae26fb..962e09049 100644
--- a/public/locales/en.json
+++ b/public/locales/en.json
@@ -1399,6 +1399,15 @@
"settings.overlay_scheme_light": "Light Mode (saturated overlays)",
"settings.theme_label": "Color Theme",
"settings.theme_description": "Choose from 15 themes including accessibility-focused options",
+ "settings.appearance_mode_label": "Appearance Mode",
+ "settings.appearance_mode_description": "Choose a fixed appearance or follow your browser and operating system setting",
+ "settings.appearance_mode_system": "Match system appearance",
+ "settings.appearance_mode_dark": "Dark",
+ "settings.appearance_mode_light": "Light",
+ "settings.dark_theme_label": "Dark Theme",
+ "settings.dark_theme_description": "Theme used when appearance is set to Dark or the system is in dark mode",
+ "settings.light_theme_label": "Light Theme",
+ "settings.light_theme_description": "Theme used when appearance is set to Light or the system is in light mode",
"settings.theme_catppuccin": "Catppuccin",
"settings.theme_mocha": "Mocha (Dark)",
"settings.theme_macchiato": "Macchiato (Medium-Dark)",
@@ -2558,6 +2567,12 @@
"theme_management.built_in": "Built-in",
"theme_management.active": "Active",
"theme_management.apply": "Apply",
+ "theme_management.apply_dark": "Apply as Dark",
+ "theme_management.apply_light": "Apply as Light",
+ "theme_management.dark_active": "Dark Active",
+ "theme_management.light_active": "Light Active",
+ "theme_management.assigned_dark": "Dark",
+ "theme_management.assigned_light": "Light",
"theme_management.clone": "Clone",
"theme_editor.edit_title": "Edit Theme",
diff --git a/src/components/CustomThemeManagement.css b/src/components/CustomThemeManagement.css
index f1f4bdd08..0340c41d1 100644
--- a/src/components/CustomThemeManagement.css
+++ b/src/components/CustomThemeManagement.css
@@ -120,14 +120,32 @@
width: fit-content;
}
+.theme-assignment-badges {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 0.25rem;
+}
+
+.assignment-badge {
+ display: inline-block;
+ padding: 0.125rem 0.5rem;
+ background: var(--ctp-blue);
+ color: var(--ctp-crust);
+ font-size: 0.75rem;
+ font-weight: 600;
+ border-radius: var(--radius-sm);
+ width: fit-content;
+}
+
.theme-card-actions {
display: flex;
+ flex-wrap: wrap;
gap: 0.5rem;
align-items: center;
}
.btn-apply {
- flex: 1;
+ flex: 1 1 120px;
padding: 0.5rem 1rem;
background: var(--ctp-blue);
color: var(--ctp-crust);
diff --git a/src/components/CustomThemeManagement.test.tsx b/src/components/CustomThemeManagement.test.tsx
new file mode 100644
index 000000000..32cbd35a7
--- /dev/null
+++ b/src/components/CustomThemeManagement.test.tsx
@@ -0,0 +1,110 @@
+/**
+ * Tests for custom theme assignment actions.
+ *
+ * @vitest-environment jsdom
+ */
+import { describe, it, expect, vi, beforeEach } from 'vitest';
+import React from 'react';
+import { render, screen } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import { CustomThemeManagement } from './CustomThemeManagement';
+
+const setDarkTheme = vi.fn();
+const setLightTheme = vi.fn();
+const setAppearanceMode = vi.fn();
+const loadCustomThemes = vi.fn();
+
+vi.mock('react-i18next', () => ({
+ useTranslation: () => ({
+ t: (key: string) => key,
+ }),
+}));
+
+vi.mock('../contexts/SettingsContext', () => ({
+ useSettings: () => ({
+ customThemes: [
+ {
+ id: 1,
+ name: 'Storm',
+ slug: 'custom-storm',
+ definition: JSON.stringify({
+ base: '#111111',
+ text: '#eeeeee',
+ blue: '#89b4fa',
+ green: '#a6e3a1',
+ yellow: '#f9e2af',
+ red: '#f38ba8',
+ }),
+ is_builtin: 0,
+ created_at: 1,
+ updated_at: 1,
+ },
+ ],
+ loadCustomThemes,
+ theme: 'mocha',
+ appearanceMode: 'system',
+ darkTheme: 'mocha',
+ lightTheme: 'latte',
+ setDarkTheme,
+ setLightTheme,
+ setAppearanceMode,
+ }),
+}));
+
+vi.mock('../contexts/AuthContext', () => ({
+ useAuth: () => ({
+ authStatus: {
+ permissions: {
+ global: {
+ themes: {
+ write: true,
+ },
+ },
+ },
+ },
+ }),
+}));
+
+vi.mock('../contexts/CsrfContext', () => ({
+ useCsrf: () => ({
+ getToken: () => 'csrf-token',
+ }),
+}));
+
+vi.mock('../services/api', () => ({
+ default: {
+ getBaseUrl: vi.fn().mockResolvedValue(''),
+ },
+}));
+
+vi.mock('./ThemeEditor', () => ({
+ ThemeEditor: () =>
,
+}));
+
+describe('CustomThemeManagement', () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ it('applies a custom theme as the dark theme without changing light theme or mode', async () => {
+ const user = userEvent.setup();
+ render();
+
+ await user.click(screen.getByRole('button', { name: 'theme_management.apply_dark' }));
+
+ expect(setDarkTheme).toHaveBeenCalledWith('custom-storm');
+ expect(setLightTheme).not.toHaveBeenCalled();
+ expect(setAppearanceMode).not.toHaveBeenCalled();
+ });
+
+ it('applies a custom theme as the light theme without changing dark theme or mode', async () => {
+ const user = userEvent.setup();
+ render();
+
+ await user.click(screen.getByRole('button', { name: 'theme_management.apply_light' }));
+
+ expect(setLightTheme).toHaveBeenCalledWith('custom-storm');
+ expect(setDarkTheme).not.toHaveBeenCalled();
+ expect(setAppearanceMode).not.toHaveBeenCalled();
+ });
+});
diff --git a/src/components/CustomThemeManagement.tsx b/src/components/CustomThemeManagement.tsx
index fcef0710c..5b2dcf7b4 100644
--- a/src/components/CustomThemeManagement.tsx
+++ b/src/components/CustomThemeManagement.tsx
@@ -9,7 +9,7 @@ import './CustomThemeManagement.css';
export const CustomThemeManagement: React.FC = () => {
const { t } = useTranslation();
- const { customThemes, loadCustomThemes, theme, setTheme } = useSettings();
+ const { customThemes, loadCustomThemes, theme, darkTheme, lightTheme, setDarkTheme, setLightTheme } = useSettings();
const { authStatus } = useAuth();
const { getToken: getCsrfToken } = useCsrf();
@@ -101,19 +101,17 @@ export const CustomThemeManagement: React.FC = () => {
return;
}
- // If we deleted the active theme, switch to mocha
- if (theme === themeSlug) {
- setTheme('mocha');
+ if (darkTheme === themeSlug) {
+ setDarkTheme('mocha');
+ }
+ if (lightTheme === themeSlug) {
+ setLightTheme('latte');
}
// Reload themes
await loadCustomThemes();
};
- const handleApply = (themeSlug: string) => {
- setTheme(themeSlug);
- };
-
if (isEditorOpen) {
return (
{
handleApply(customTheme.slug)}
+ onApplyDark={() => setDarkTheme(customTheme.slug)}
+ onApplyLight={() => setLightTheme(customTheme.slug)}
onEdit={() => handleEdit(customTheme)}
onClone={() => handleClone(customTheme)}
onDelete={() => handleDelete(customTheme.slug)}
@@ -171,9 +171,11 @@ export const CustomThemeManagement: React.FC = () => {
interface ThemeCardProps {
theme: CustomTheme;
- isActive: boolean;
+ isDarkTheme: boolean;
+ isLightTheme: boolean;
canWrite: boolean;
- onApply: () => void;
+ onApplyDark: () => void;
+ onApplyLight: () => void;
onEdit: () => void;
onClone: () => void;
onDelete: () => void;
@@ -181,9 +183,11 @@ interface ThemeCardProps {
const ThemeCard: React.FC = ({
theme,
- isActive,
+ isDarkTheme,
+ isLightTheme,
canWrite,
- onApply,
+ onApplyDark,
+ onApplyLight,
onEdit,
onClone,
onDelete
@@ -205,9 +209,10 @@ const ThemeCard: React.FC = ({
definition.yellow,
definition.red
].filter(Boolean);
+ const isAssigned = isDarkTheme || isLightTheme;
return (
-
+
{previewColors.map((color, i) => (
@@ -227,15 +232,29 @@ const ThemeCard: React.FC
= ({
{theme.is_builtin === 1 && (
{t('theme_management.built_in')}
)}
+ {isAssigned && (
+
+ {isDarkTheme && {t('theme_management.assigned_dark')}}
+ {isLightTheme && {t('theme_management.assigned_light')}}
+
+ )}
+
+
{canWrite && !theme.is_builtin && (
diff --git a/src/components/SettingsTab.tsx b/src/components/SettingsTab.tsx
index 030cdaaec..bc2c1bec9 100644
--- a/src/components/SettingsTab.tsx
+++ b/src/components/SettingsTab.tsx
@@ -18,7 +18,7 @@ import FirmwareUpdateSection from './configuration/FirmwareUpdateSection';
import ChannelDatabaseSection from './configuration/ChannelDatabaseSection';
import { CustomThemeManagement } from './CustomThemeManagement';
import { CustomTilesetManager } from './CustomTilesetManager';
-import { type Theme, type NodeHopsCalculation, useSettings } from '../contexts/SettingsContext';
+import { type Theme, type AppearanceMode, type NodeHopsCalculation, useSettings } from '../contexts/SettingsContext';
import { type SortOption as DashboardSortOption } from './Dashboard/types';
import { useUI } from '../contexts/UIContext';
import { LanguageSelector } from './LanguageSelector';
@@ -121,7 +121,6 @@ const SettingsTab: React.FC
= ({
mapTileset,
mapPinStyle,
iconStyle,
- theme,
language,
solarMonitoringEnabled,
solarMonitoringLatitude,
@@ -147,7 +146,6 @@ const SettingsTab: React.FC = ({
onMapTilesetChange,
onMapPinStyleChange,
onIconStyleChange,
- onThemeChange,
onLanguageChange,
onSolarMonitoringEnabledChange,
onSolarMonitoringLatitudeChange,
@@ -188,6 +186,12 @@ const SettingsTab: React.FC = ({
setDefaultMapCenterZoom,
defaultLandingPage,
setDefaultLandingPage,
+ appearanceMode,
+ setAppearanceMode,
+ darkTheme,
+ setDarkTheme,
+ lightTheme,
+ setLightTheme,
} = useSettings();
const { data: availableSources = [] } = useDashboardSources();
const { showIncompleteNodes, setShowIncompleteNodes } = useUI();
@@ -214,7 +218,9 @@ const SettingsTab: React.FC = ({
const [localDefaultMapCenterLon, setLocalDefaultMapCenterLon] = useState(defaultMapCenterLon);
const [localDefaultMapCenterZoom, setLocalDefaultMapCenterZoom] = useState(defaultMapCenterZoom);
const [localDefaultLandingPage, setLocalDefaultLandingPage] = useState(defaultLandingPage);
- const [localTheme, setLocalTheme] = useState(theme);
+ const [localAppearanceMode, setLocalAppearanceMode] = useState(appearanceMode);
+ const [localDarkTheme, setLocalDarkTheme] = useState(darkTheme);
+ const [localLightTheme, setLocalLightTheme] = useState(lightTheme);
const [localNodeHopsCalculation, setLocalNodeHopsCalculation] = useState(nodeHopsCalculation);
const [localDashboardSortOption, setLocalDashboardSortOption] = useState(preferredDashboardSortOption);
const [localPacketLogEnabled, setLocalPacketLogEnabled] = useState(false);
@@ -385,7 +391,9 @@ const SettingsTab: React.FC = ({
setLocalDefaultMapCenterLon(defaultMapCenterLon);
setLocalDefaultMapCenterZoom(defaultMapCenterZoom);
setLocalDefaultLandingPage(defaultLandingPage);
- setLocalTheme(theme);
+ setLocalAppearanceMode(appearanceMode);
+ setLocalDarkTheme(darkTheme);
+ setLocalLightTheme(lightTheme);
setLocalNodeHopsCalculation(nodeHopsCalculation);
setLocalDashboardSortOption(preferredDashboardSortOption);
setLocalSolarMonitoringEnabled(solarMonitoringEnabled);
@@ -394,7 +402,7 @@ const SettingsTab: React.FC = ({
setLocalSolarMonitoringAzimuth(solarMonitoringAzimuth);
setLocalSolarMonitoringDeclination(solarMonitoringDeclination);
setLocalHideIncompleteNodes(!showIncompleteNodes);
- }, [maxNodeAgeHours, inactiveNodeThresholdHours, inactiveNodeCheckIntervalMinutes, inactiveNodeCooldownHours, temperatureUnit, distanceUnit, positionHistoryLineStyle, telemetryVisualizationHours, favoriteTelemetryStorageDays, preferredSortField, preferredSortDirection, timeFormat, dateFormat, mapTileset, mapPinStyle, nodeHopsCalculation, preferredDashboardSortOption, solarMonitoringEnabled, solarMonitoringLatitude, solarMonitoringLongitude, solarMonitoringAzimuth, solarMonitoringDeclination, showIncompleteNodes, defaultMapCenterLat, defaultMapCenterLon, defaultMapCenterZoom, defaultLandingPage]);
+ }, [maxNodeAgeHours, inactiveNodeThresholdHours, inactiveNodeCheckIntervalMinutes, inactiveNodeCooldownHours, temperatureUnit, distanceUnit, positionHistoryLineStyle, telemetryVisualizationHours, favoriteTelemetryStorageDays, preferredSortField, preferredSortDirection, timeFormat, dateFormat, mapTileset, mapPinStyle, nodeHopsCalculation, preferredDashboardSortOption, solarMonitoringEnabled, solarMonitoringLatitude, solarMonitoringLongitude, solarMonitoringAzimuth, solarMonitoringDeclination, showIncompleteNodes, defaultMapCenterLat, defaultMapCenterLon, defaultMapCenterZoom, defaultLandingPage, appearanceMode, darkTheme, lightTheme]);
// Default solar monitoring lat/long to device position if still at 0
useEffect(() => {
@@ -443,7 +451,9 @@ const SettingsTab: React.FC = ({
localDefaultMapCenterLon !== defaultMapCenterLon ||
localDefaultMapCenterZoom !== defaultMapCenterZoom ||
localDefaultLandingPage !== defaultLandingPage ||
- localTheme !== theme ||
+ localAppearanceMode !== appearanceMode ||
+ localDarkTheme !== darkTheme ||
+ localLightTheme !== lightTheme ||
localNodeHopsCalculation !== nodeHopsCalculation ||
localDashboardSortOption !== preferredDashboardSortOption ||
localPacketLogEnabled !== initialPacketMonitorSettings.enabled ||
@@ -465,8 +475,8 @@ const SettingsTab: React.FC = ({
JSON.stringify(localAnalyticsConfig) !== initialAnalyticsConfig ||
localAppriseApiServerUrl !== initialAppriseApiServerUrl;
setHasChanges(changed);
- }, [localMaxNodeAge, localInactiveNodeThresholdHours, localInactiveNodeCheckIntervalMinutes, localInactiveNodeCooldownHours, localTemperatureUnit, localDistanceUnit, localPositionHistoryLineStyle, localTelemetryHours, localFavoriteTelemetryStorageDays, localPreferredSortField, localPreferredSortDirection, localTimeFormat, localDateFormat, localMapTileset, localMapPinStyle, localIconStyle, localNeighborInfoMinZoom, localDefaultMapCenterLat, localDefaultMapCenterLon, localDefaultMapCenterZoom, localDefaultLandingPage, localTheme, localNodeHopsCalculation, localDashboardSortOption,
- maxNodeAgeHours, inactiveNodeThresholdHours, inactiveNodeCheckIntervalMinutes, inactiveNodeCooldownHours, temperatureUnit, distanceUnit, positionHistoryLineStyle, telemetryVisualizationHours, favoriteTelemetryStorageDays, preferredSortField, preferredSortDirection, timeFormat, dateFormat, mapTileset, mapPinStyle, iconStyle, neighborInfoMinZoom, defaultMapCenterLat, defaultMapCenterLon, defaultMapCenterZoom, defaultLandingPage, theme, nodeHopsCalculation, preferredDashboardSortOption,
+ }, [localMaxNodeAge, localInactiveNodeThresholdHours, localInactiveNodeCheckIntervalMinutes, localInactiveNodeCooldownHours, localTemperatureUnit, localDistanceUnit, localPositionHistoryLineStyle, localTelemetryHours, localFavoriteTelemetryStorageDays, localPreferredSortField, localPreferredSortDirection, localTimeFormat, localDateFormat, localMapTileset, localMapPinStyle, localIconStyle, localNeighborInfoMinZoom, localDefaultMapCenterLat, localDefaultMapCenterLon, localDefaultMapCenterZoom, localDefaultLandingPage, localAppearanceMode, localDarkTheme, localLightTheme, localNodeHopsCalculation, localDashboardSortOption,
+ maxNodeAgeHours, inactiveNodeThresholdHours, inactiveNodeCheckIntervalMinutes, inactiveNodeCooldownHours, temperatureUnit, distanceUnit, positionHistoryLineStyle, telemetryVisualizationHours, favoriteTelemetryStorageDays, preferredSortField, preferredSortDirection, timeFormat, dateFormat, mapTileset, mapPinStyle, iconStyle, neighborInfoMinZoom, defaultMapCenterLat, defaultMapCenterLon, defaultMapCenterZoom, defaultLandingPage, appearanceMode, darkTheme, lightTheme, nodeHopsCalculation, preferredDashboardSortOption,
localPacketLogEnabled, localPacketLogMaxCount, localPacketLogMaxAgeHours, initialPacketMonitorSettings,
localSolarMonitoringEnabled, localSolarMonitoringLatitude, localSolarMonitoringLongitude, localSolarMonitoringAzimuth, localSolarMonitoringDeclination,
solarMonitoringEnabled, solarMonitoringLatitude, solarMonitoringLongitude, solarMonitoringAzimuth, solarMonitoringDeclination,
@@ -499,7 +509,9 @@ const SettingsTab: React.FC = ({
setLocalDefaultMapCenterLon(defaultMapCenterLon);
setLocalDefaultMapCenterZoom(defaultMapCenterZoom);
setLocalDefaultLandingPage(defaultLandingPage);
- setLocalTheme(theme);
+ setLocalAppearanceMode(appearanceMode);
+ setLocalDarkTheme(darkTheme);
+ setLocalLightTheme(lightTheme);
setLocalNodeHopsCalculation(nodeHopsCalculation);
setLocalDashboardSortOption(preferredDashboardSortOption);
setLocalPacketLogEnabled(initialPacketMonitorSettings.enabled);
@@ -523,16 +535,26 @@ const SettingsTab: React.FC = ({
}, [maxNodeAgeHours, inactiveNodeThresholdHours, inactiveNodeCheckIntervalMinutes,
inactiveNodeCooldownHours, temperatureUnit, distanceUnit, telemetryVisualizationHours,
favoriteTelemetryStorageDays, preferredSortField, preferredSortDirection, timeFormat,
- dateFormat, mapTileset, mapPinStyle, iconStyle, neighborInfoMinZoom, defaultMapCenterLat, defaultMapCenterLon, defaultMapCenterZoom, defaultLandingPage, theme, nodeHopsCalculation, preferredDashboardSortOption,
+ dateFormat, mapTileset, mapPinStyle, iconStyle, neighborInfoMinZoom, defaultMapCenterLat, defaultMapCenterLon, defaultMapCenterZoom, defaultLandingPage, appearanceMode, darkTheme, lightTheme, nodeHopsCalculation, preferredDashboardSortOption,
initialPacketMonitorSettings, solarMonitoringEnabled, solarMonitoringLatitude,
solarMonitoringLongitude, solarMonitoringAzimuth, solarMonitoringDeclination, showIncompleteNodes,
initialHomoglyphEnabled, initialMeshcoreAdvancedPathEdit, initialLocalStatsIntervalMinutes, initialNodeDimmingSettings,
setNodeDimmingEnabled, setNodeDimmingStartHours, setNodeDimmingMinOpacity,
initialAnalyticsProvider, initialAnalyticsConfig, initialAppriseApiServerUrl]);
+ const getLocalEffectiveTheme = useCallback((): Theme => {
+ if (localAppearanceMode === 'dark') return localDarkTheme;
+ if (localAppearanceMode === 'light') return localLightTheme;
+ const systemIsDark = typeof window !== 'undefined' && window.matchMedia
+ ? window.matchMedia('(prefers-color-scheme: dark)').matches
+ : true;
+ return systemIsDark ? localDarkTheme : localLightTheme;
+ }, [localAppearanceMode, localDarkTheme, localLightTheme]);
+
const handleSave = useCallback(async () => {
setIsSaving(true);
try {
+ const localEffectiveTheme = getLocalEffectiveTheme();
const settings = {
maxNodeAgeHours: localMaxNodeAge,
inactiveNodeThresholdHours: localInactiveNodeThresholdHours,
@@ -555,7 +577,10 @@ const SettingsTab: React.FC = ({
defaultMapCenterLon: localDefaultMapCenterLon !== null ? localDefaultMapCenterLon.toString() : '',
defaultMapCenterZoom: localDefaultMapCenterZoom !== null ? localDefaultMapCenterZoom.toString() : '',
defaultLandingPage: localDefaultLandingPage,
- theme: localTheme,
+ theme: localEffectiveTheme,
+ appearanceMode: localAppearanceMode,
+ darkTheme: localDarkTheme,
+ lightTheme: localLightTheme,
packet_log_enabled: localPacketLogEnabled ? '1' : '0',
packet_log_max_count: localPacketLogMaxCount.toString(),
packet_log_max_age_hours: localPacketLogMaxAgeHours.toString(),
@@ -606,7 +631,9 @@ const SettingsTab: React.FC = ({
setDefaultMapCenterLon(localDefaultMapCenterLon);
setDefaultMapCenterZoom(localDefaultMapCenterZoom);
setDefaultLandingPage(localDefaultLandingPage);
- onThemeChange(localTheme);
+ setAppearanceMode(localAppearanceMode);
+ setDarkTheme(localDarkTheme);
+ setLightTheme(localLightTheme);
setNodeHopsCalculation(localNodeHopsCalculation);
setPreferredDashboardSortOption(localDashboardSortOption);
onSolarMonitoringEnabledChange(localSolarMonitoringEnabled);
@@ -641,7 +668,7 @@ const SettingsTab: React.FC = ({
localInactiveNodeCheckIntervalMinutes, localInactiveNodeCooldownHours,
localTemperatureUnit, localDistanceUnit, localPositionHistoryLineStyle, localTelemetryHours,
localFavoriteTelemetryStorageDays, localPreferredSortField, localPreferredSortDirection,
- localTimeFormat, localDateFormat, localMapTileset, localMapPinStyle, localIconStyle, localNeighborInfoMinZoom, localDefaultMapCenterLat, localDefaultMapCenterLon, localDefaultMapCenterZoom, localDefaultLandingPage, localTheme,
+ localTimeFormat, localDateFormat, localMapTileset, localMapPinStyle, localIconStyle, localNeighborInfoMinZoom, localDefaultMapCenterLat, localDefaultMapCenterLon, localDefaultMapCenterZoom, localDefaultLandingPage, localAppearanceMode, localDarkTheme, localLightTheme, getLocalEffectiveTheme,
localNodeHopsCalculation, localDashboardSortOption, localPacketLogEnabled, localPacketLogMaxCount, localPacketLogMaxAgeHours,
localSolarMonitoringEnabled, localSolarMonitoringLatitude, localSolarMonitoringLongitude,
localSolarMonitoringAzimuth, localSolarMonitoringDeclination, localHideIncompleteNodes, localHomoglyphEnabled, localLocalStatsIntervalMinutes,
@@ -649,7 +676,7 @@ const SettingsTab: React.FC = ({
onInactiveNodeCooldownHoursChange, onTemperatureUnitChange, onDistanceUnitChange, onPositionHistoryLineStyleChange,
onTelemetryVisualizationChange, onFavoriteTelemetryStorageDaysChange, onPreferredSortFieldChange,
onPreferredSortDirectionChange, onTimeFormatChange, onDateFormatChange, onMapTilesetChange,
- onMapPinStyleChange, setNeighborInfoMinZoom, setDefaultMapCenterLat, setDefaultMapCenterLon, setDefaultMapCenterZoom, setDefaultLandingPage, onThemeChange, setNodeHopsCalculation, setPreferredDashboardSortOption, onSolarMonitoringEnabledChange,
+ onMapPinStyleChange, setNeighborInfoMinZoom, setDefaultMapCenterLat, setDefaultMapCenterLon, setDefaultMapCenterZoom, setDefaultLandingPage, setAppearanceMode, setDarkTheme, setLightTheme, setNodeHopsCalculation, setPreferredDashboardSortOption, onSolarMonitoringEnabledChange,
onSolarMonitoringLatitudeChange, onSolarMonitoringLongitudeChange, onSolarMonitoringAzimuthChange,
onSolarMonitoringDeclinationChange, setShowIncompleteNodes, showToast, t,
nodeDimmingEnabled, nodeDimmingStartHours, nodeDimmingMinOpacity,
@@ -771,7 +798,9 @@ const SettingsTab: React.FC = ({
setLocalDateFormat('MM/DD/YYYY');
setLocalMapTileset('osm');
setLocalMapPinStyle('meshmonitor');
- setLocalTheme('mocha');
+ setLocalAppearanceMode('system');
+ setLocalDarkTheme('mocha');
+ setLocalLightTheme('latte');
setLocalNodeHopsCalculation('nodeinfo');
setLocalDashboardSortOption('custom');
setLocalPacketLogEnabled(false);
@@ -796,7 +825,9 @@ const SettingsTab: React.FC = ({
onDateFormatChange('MM/DD/YYYY');
onMapTilesetChange('osm');
onMapPinStyleChange('meshmonitor');
- onThemeChange('mocha');
+ setAppearanceMode('system');
+ setDarkTheme('mocha');
+ setLightTheme('latte');
setNodeHopsCalculation('nodeinfo');
setPreferredDashboardSortOption('custom');
onSolarMonitoringEnabledChange(false);
@@ -936,6 +967,43 @@ const SettingsTab: React.FC = ({
}
};
+ const renderThemeOptions = () => (
+ <>
+
+
+
+
+ {customThemes.length > 0 && (
+
+ )}
+ >
+ );
+
return (
@@ -1162,48 +1230,47 @@ const SettingsTab: React.FC
= ({
{show('settings-appearance') &&
{t('settings.appearance')}
-
+
+
+ {t('settings.dark_theme_label')}
+ {t('settings.dark_theme_description')}
+
+
+
+
+
+ {t('settings.light_theme_label')}
+ {t('settings.light_theme_description')}
+
+
@@ -2062,4 +2129,4 @@ const SettingsTab: React.FC
= ({
);
};
-export default SettingsTab;
\ No newline at end of file
+export default SettingsTab;
diff --git a/src/contexts/SettingsContext.test.tsx b/src/contexts/SettingsContext.test.tsx
index d4ab52bd6..9103eaa06 100644
--- a/src/contexts/SettingsContext.test.tsx
+++ b/src/contexts/SettingsContext.test.tsx
@@ -78,6 +78,37 @@ vi.mock('../utils/temperature', () => ({
const mockFetch = vi.fn();
global.fetch = mockFetch;
+let mockSystemIsDark = true;
+let mediaQueryListeners: Array<(event: MediaQueryListEvent) => void> = [];
+
+const installMatchMediaMock = () => {
+ mediaQueryListeners = [];
+ Object.defineProperty(window, 'matchMedia', {
+ writable: true,
+ value: vi.fn().mockImplementation((query: string) => ({
+ matches: query === '(prefers-color-scheme: dark)' ? mockSystemIsDark : false,
+ media: query,
+ onchange: null,
+ addEventListener: (_event: string, listener: (event: MediaQueryListEvent) => void) => {
+ mediaQueryListeners.push(listener);
+ },
+ removeEventListener: (_event: string, listener: (event: MediaQueryListEvent) => void) => {
+ mediaQueryListeners = mediaQueryListeners.filter(item => item !== listener);
+ },
+ addListener: vi.fn(),
+ removeListener: vi.fn(),
+ dispatchEvent: vi.fn(),
+ })),
+ });
+};
+
+const setMockSystemAppearance = async (isDark: boolean) => {
+ mockSystemIsDark = isDark;
+ await act(async () => {
+ mediaQueryListeners.forEach(listener => listener({ matches: isDark } as MediaQueryListEvent));
+ });
+};
+
// Default successful settings response
const defaultSettingsResponse = {
maxNodeAgeHours: '48',
@@ -265,6 +296,8 @@ describe('SettingsContext Types', () => {
describe('SettingsProvider', () => {
beforeEach(() => {
localStorage.clear();
+ mockSystemIsDark = true;
+ installMatchMediaMock();
mockFetch.mockReset();
createFetchMock();
});
@@ -317,6 +350,210 @@ describe('SettingsProvider', () => {
expect(contextValue.setTimeFormat).toBeDefined();
});
+ it('should default new users to system appearance with mocha dark and latte light themes', async () => {
+ mockSystemIsDark = false;
+ installMatchMediaMock();
+ mockFetch.mockReset();
+ createFetchMock({});
+ const { SettingsProvider, useSettings } = await import('./SettingsContext');
+
+ let contextValue: any;
+ const Consumer = () => {
+ contextValue = useSettings();
+ return loaded
;
+ };
+
+ await act(async () => {
+ render(
+
+
+
+ );
+ });
+
+ await waitFor(() => {
+ expect(contextValue.isLoading).toBe(false);
+ });
+
+ expect(contextValue.appearanceMode).toBe('system');
+ expect(contextValue.darkTheme).toBe('mocha');
+ expect(contextValue.lightTheme).toBe('latte');
+ expect(contextValue.theme).toBe('latte');
+ expect(document.documentElement.getAttribute('data-theme')).toBe('latte');
+ });
+
+ it('should migrate legacy mocha users to system appearance with mocha and latte', async () => {
+ localStorage.setItem('theme', 'mocha');
+ mockFetch.mockReset();
+ createFetchMock({});
+ const { SettingsProvider, useSettings } = await import('./SettingsContext');
+
+ let contextValue: any;
+ const Consumer = () => {
+ contextValue = useSettings();
+ return loaded
;
+ };
+
+ await act(async () => {
+ render(
+
+
+
+ );
+ });
+
+ await waitFor(() => {
+ expect(contextValue.isLoading).toBe(false);
+ });
+
+ expect(contextValue.appearanceMode).toBe('system');
+ expect(contextValue.darkTheme).toBe('mocha');
+ expect(contextValue.lightTheme).toBe('latte');
+ expect(contextValue.theme).toBe('mocha');
+ });
+
+ it('should migrate legacy non-mocha users to dark mode with both theme slots set to the legacy theme', async () => {
+ localStorage.setItem('theme', 'dracula');
+ mockFetch.mockReset();
+ createFetchMock({});
+ const { SettingsProvider, useSettings } = await import('./SettingsContext');
+
+ let contextValue: any;
+ const Consumer = () => {
+ contextValue = useSettings();
+ return loaded
;
+ };
+
+ await act(async () => {
+ render(
+
+
+
+ );
+ });
+
+ await waitFor(() => {
+ expect(contextValue.isLoading).toBe(false);
+ });
+
+ expect(contextValue.appearanceMode).toBe('dark');
+ expect(contextValue.darkTheme).toBe('dracula');
+ expect(contextValue.lightTheme).toBe('dracula');
+ expect(contextValue.theme).toBe('dracula');
+ expect(document.documentElement.getAttribute('data-theme')).toBe('dracula');
+ });
+
+ it('should switch effective theme when system appearance changes in system mode', async () => {
+ mockSystemIsDark = false;
+ installMatchMediaMock();
+ mockFetch.mockReset();
+ createFetchMock({});
+ const { SettingsProvider, useSettings } = await import('./SettingsContext');
+
+ let contextValue: any;
+ const Consumer = () => {
+ contextValue = useSettings();
+ return loaded
;
+ };
+
+ await act(async () => {
+ render(
+
+
+
+ );
+ });
+
+ await waitFor(() => {
+ expect(contextValue.theme).toBe('latte');
+ });
+
+ await setMockSystemAppearance(true);
+
+ await waitFor(() => {
+ expect(contextValue.theme).toBe('mocha');
+ expect(document.documentElement.getAttribute('data-theme')).toBe('mocha');
+ });
+ });
+
+ it('should ignore system appearance changes in manual dark and light modes', async () => {
+ mockFetch.mockReset();
+ createFetchMock({});
+ const { SettingsProvider, useSettings } = await import('./SettingsContext');
+
+ let contextValue: any;
+ const Consumer = () => {
+ contextValue = useSettings();
+ return loaded
;
+ };
+
+ await act(async () => {
+ render(
+
+
+
+ );
+ });
+
+ await waitFor(() => {
+ expect(contextValue.isLoading).toBe(false);
+ });
+
+ await act(async () => {
+ contextValue.setAppearanceMode('dark');
+ });
+ await setMockSystemAppearance(false);
+
+ await waitFor(() => {
+ expect(contextValue.theme).toBe('mocha');
+ });
+
+ await act(async () => {
+ contextValue.setAppearanceMode('light');
+ });
+ await setMockSystemAppearance(true);
+
+ await waitFor(() => {
+ expect(contextValue.theme).toBe('latte');
+ });
+ });
+
+ it('should allow custom themes in dark and light theme slots', async () => {
+ mockFetch.mockReset();
+ createFetchMock({});
+ const { SettingsProvider, useSettings } = await import('./SettingsContext');
+
+ let contextValue: any;
+ const Consumer = () => {
+ contextValue = useSettings();
+ return loaded
;
+ };
+
+ await act(async () => {
+ render(
+
+
+
+ );
+ });
+
+ await waitFor(() => {
+ expect(contextValue.isLoading).toBe(false);
+ });
+
+ await act(async () => {
+ contextValue.setDarkTheme('custom-night');
+ contextValue.setLightTheme('custom-day');
+ });
+
+ await waitFor(() => {
+ expect(contextValue.darkTheme).toBe('custom-night');
+ expect(contextValue.lightTheme).toBe('custom-day');
+ });
+ expect(localStorage.getItem('darkTheme')).toBe('custom-night');
+ expect(localStorage.getItem('lightTheme')).toBe('custom-day');
+ });
+
it('should initialize timeFormat from localStorage', async () => {
localStorage.setItem('timeFormat', '12');
const { SettingsProvider, useSettings } = await import('./SettingsContext');
diff --git a/src/contexts/SettingsContext.tsx b/src/contexts/SettingsContext.tsx
index 0a3c3d85e..6e41635db 100644
--- a/src/contexts/SettingsContext.tsx
+++ b/src/contexts/SettingsContext.tsx
@@ -30,6 +30,7 @@ export type DateFormat = 'MM/DD/YYYY' | 'DD/MM/YYYY' | 'YYYY-MM-DD';
export type MapPinStyle = 'meshmonitor' | 'official';
export type IconStyle = 'lucide' | 'emoji';
export type NodeHopsCalculation = 'nodeinfo' | 'traceroute' | 'messages';
+export type AppearanceMode = 'system' | 'dark' | 'light';
// Built-in theme types
export type BuiltInTheme =
@@ -43,6 +44,13 @@ export type BuiltInTheme =
// Theme can be a built-in theme or a custom theme slug
export type Theme = BuiltInTheme | string;
+interface ThemePreferences {
+ appearanceMode: AppearanceMode;
+ darkTheme: Theme;
+ lightTheme: Theme;
+ effectiveTheme: Theme;
+}
+
// Custom theme definition from the API
export interface CustomTheme {
id: number;
@@ -82,6 +90,9 @@ interface SettingsContextType {
defaultMapCenterZoom: number | null;
defaultLandingPage: string;
theme: Theme;
+ appearanceMode: AppearanceMode;
+ darkTheme: Theme;
+ lightTheme: Theme;
language: string;
customThemes: CustomTheme[];
customTilesets: CustomTileset[];
@@ -124,6 +135,9 @@ interface SettingsContextType {
setDefaultMapCenterZoom: (zoom: number | null) => void;
setDefaultLandingPage: (value: string) => void;
setTheme: (theme: Theme) => void;
+ setAppearanceMode: (mode: AppearanceMode) => void;
+ setDarkTheme: (theme: Theme) => void;
+ setLightTheme: (theme: Theme) => void;
setLanguage: (language: string) => void;
loadCustomThemes: () => Promise;
addCustomTileset: (tileset: Omit) => Promise;
@@ -176,10 +190,62 @@ const detectBaseUrlFromLocation = (): string => {
return baseSegments.length > 0 ? '/' + baseSegments.join('/') : '';
};
+const DEFAULT_DARK_THEME: Theme = 'mocha';
+const DEFAULT_LIGHT_THEME: Theme = 'latte';
+const BUILT_IN_THEMES: BuiltInTheme[] = [
+ 'mocha', 'macchiato', 'frappe', 'latte',
+ 'nord', 'dracula',
+ 'solarized-dark', 'solarized-light',
+ 'gruvbox-dark', 'gruvbox-light',
+ 'high-contrast-dark', 'high-contrast-light',
+ 'protanopia', 'deuteranopia', 'tritanopia'
+];
+
+const isAppearanceMode = (value: string | null): value is AppearanceMode => (
+ value === 'system' || value === 'dark' || value === 'light'
+);
+
+const prefersDarkMode = (): boolean => {
+ if (typeof window === 'undefined' || !window.matchMedia) return true;
+ return window.matchMedia('(prefers-color-scheme: dark)').matches;
+};
+
+const getEffectiveTheme = (mode: AppearanceMode, darkTheme: Theme, lightTheme: Theme, systemIsDark: boolean): Theme => {
+ if (mode === 'dark') return darkTheme;
+ if (mode === 'light') return lightTheme;
+ return systemIsDark ? darkTheme : lightTheme;
+};
+
+const getInitialThemePreferences = (): ThemePreferences => {
+ const storedMode = localStorage.getItem('appearanceMode');
+ const storedDarkTheme = localStorage.getItem('darkTheme');
+ const storedLightTheme = localStorage.getItem('lightTheme');
+ const legacyTheme = localStorage.getItem('theme');
+
+ const hasNewPreferences = isAppearanceMode(storedMode) || storedDarkTheme || storedLightTheme;
+ const darkTheme = storedDarkTheme || (hasNewPreferences ? DEFAULT_DARK_THEME : legacyTheme || DEFAULT_DARK_THEME);
+ const lightTheme = storedLightTheme || (hasNewPreferences ? DEFAULT_LIGHT_THEME : legacyTheme && legacyTheme !== DEFAULT_DARK_THEME ? legacyTheme : DEFAULT_LIGHT_THEME);
+ const appearanceMode = isAppearanceMode(storedMode)
+ ? storedMode
+ : legacyTheme && legacyTheme !== DEFAULT_DARK_THEME
+ ? 'dark'
+ : 'system';
+ const effectiveTheme = getEffectiveTheme(appearanceMode, darkTheme, lightTheme, prefersDarkMode());
+
+ localStorage.setItem('appearanceMode', appearanceMode);
+ localStorage.setItem('darkTheme', darkTheme);
+ localStorage.setItem('lightTheme', lightTheme);
+ localStorage.setItem('theme', effectiveTheme);
+
+ return { appearanceMode, darkTheme, lightTheme, effectiveTheme };
+};
+
export const SettingsProvider: React.FC = ({ children, baseUrl: baseUrlProp }) => {
const baseUrl = baseUrlProp ?? detectBaseUrlFromLocation();
const { getToken: getCsrfToken } = useCsrf();
const [isLoading, setIsLoading] = useState(true);
+ const [initialThemePreferences] = useState(() => getInitialThemePreferences());
+ const [systemIsDark, setSystemIsDark] = useState(() => prefersDarkMode());
const [maxNodeAgeHours, setMaxNodeAgeHoursState] = useState(() => {
const saved = localStorage.getItem('maxNodeAgeHours');
@@ -304,16 +370,19 @@ export const SettingsProvider: React.FC = ({ children, ba
});
const [theme, setThemeState] = useState(() => {
- const saved = localStorage.getItem('theme');
- const validThemes: Theme[] = [
- 'mocha', 'macchiato', 'frappe', 'latte',
- 'nord', 'dracula',
- 'solarized-dark', 'solarized-light',
- 'gruvbox-dark', 'gruvbox-light',
- 'high-contrast-dark', 'high-contrast-light',
- 'protanopia', 'deuteranopia', 'tritanopia'
- ];
- return (saved && validThemes.includes(saved as Theme) ? saved : 'mocha') as Theme;
+ return initialThemePreferences.effectiveTheme;
+ });
+
+ const [appearanceMode, setAppearanceModeState] = useState(() => {
+ return initialThemePreferences.appearanceMode;
+ });
+
+ const [darkTheme, setDarkThemeState] = useState(() => {
+ return initialThemePreferences.darkTheme;
+ });
+
+ const [lightTheme, setLightThemeState] = useState(() => {
+ return initialThemePreferences.lightTheme;
});
const [language, setLanguageState] = useState(() => {
@@ -629,25 +698,13 @@ export const SettingsProvider: React.FC = ({ children, ba
}
}, [customThemes]);
- /**
- * Built-in theme names for validation
- */
- const builtInThemes: BuiltInTheme[] = [
- 'mocha', 'macchiato', 'frappe', 'latte',
- 'nord', 'dracula',
- 'solarized-dark', 'solarized-light',
- 'gruvbox-dark', 'gruvbox-light',
- 'high-contrast-dark', 'high-contrast-light',
- 'protanopia', 'deuteranopia', 'tritanopia'
- ];
-
- const setTheme = (newTheme: Theme) => {
- logger.debug(`🔄 setTheme called with: ${newTheme}`);
+ const applyTheme = React.useCallback((newTheme: Theme) => {
+ logger.debug(`🔄 applyTheme called with: ${newTheme}`);
setThemeState(newTheme);
localStorage.setItem('theme', newTheme);
// Check if this is a built-in or custom theme
- const isBuiltIn = builtInThemes.includes(newTheme as BuiltInTheme);
+ const isBuiltIn = BUILT_IN_THEMES.includes(newTheme as BuiltInTheme);
logger.debug(`📝 Is built-in theme: ${isBuiltIn}`);
if (isBuiltIn) {
@@ -667,7 +724,41 @@ export const SettingsProvider: React.FC = ({ children, ba
// Apply the custom theme CSS
applyCustomThemeCSS(newTheme);
}
- };
+ }, [applyCustomThemeCSS]);
+
+ const applyAppearancePreferences = React.useCallback((
+ mode: AppearanceMode,
+ nextDarkTheme: Theme,
+ nextLightTheme: Theme,
+ nextSystemIsDark = systemIsDark
+ ) => {
+ applyTheme(getEffectiveTheme(mode, nextDarkTheme, nextLightTheme, nextSystemIsDark));
+ }, [applyTheme, systemIsDark]);
+
+ const setAppearanceMode = React.useCallback((mode: AppearanceMode) => {
+ setAppearanceModeState(mode);
+ localStorage.setItem('appearanceMode', mode);
+ }, []);
+
+ const setDarkTheme = React.useCallback((newTheme: Theme) => {
+ setDarkThemeState(newTheme);
+ localStorage.setItem('darkTheme', newTheme);
+ }, []);
+
+ const setLightTheme = React.useCallback((newTheme: Theme) => {
+ setLightThemeState(newTheme);
+ localStorage.setItem('lightTheme', newTheme);
+ }, []);
+
+ const setTheme = React.useCallback((newTheme: Theme) => {
+ setAppearanceModeState('dark');
+ setDarkThemeState(newTheme);
+ setLightThemeState(newTheme);
+ localStorage.setItem('appearanceMode', 'dark');
+ localStorage.setItem('darkTheme', newTheme);
+ localStorage.setItem('lightTheme', newTheme);
+ applyTheme(newTheme);
+ }, [applyTheme]);
const setLanguage = async (lang: string) => {
setLanguageState(lang);
@@ -1140,21 +1231,40 @@ export const SettingsProvider: React.FC = ({ children, ba
localStorage.setItem('defaultLandingPage', settings.defaultLandingPage);
}
- if (settings.theme) {
- // Accept any theme (built-in or custom)
- setThemeState(settings.theme as Theme);
- localStorage.setItem('theme', settings.theme);
-
- // Check if it's a built-in or custom theme
- const isBuiltIn = builtInThemes.includes(settings.theme as BuiltInTheme);
-
- if (isBuiltIn) {
- document.documentElement.setAttribute('data-theme', settings.theme);
- } else {
- // Custom theme will be applied after custom themes are loaded
- document.documentElement.setAttribute('data-theme', 'custom');
- logger.debug(`🎨 Custom theme '${settings.theme}' will be applied after themes load`);
- }
+ if (
+ typeof settings.theme === 'string' ||
+ typeof settings.appearanceMode === 'string' ||
+ typeof settings.darkTheme === 'string' ||
+ typeof settings.lightTheme === 'string'
+ ) {
+ const hasNewThemePreferences = (
+ isAppearanceMode(settings.appearanceMode) ||
+ typeof settings.darkTheme === 'string' ||
+ typeof settings.lightTheme === 'string'
+ );
+ const legacyTheme = typeof settings.theme === 'string' ? settings.theme as Theme : null;
+ const nextAppearanceMode: AppearanceMode = isAppearanceMode(settings.appearanceMode)
+ ? settings.appearanceMode
+ : legacyTheme && legacyTheme !== DEFAULT_DARK_THEME && !hasNewThemePreferences
+ ? 'dark'
+ : 'system';
+ const nextDarkTheme: Theme = typeof settings.darkTheme === 'string'
+ ? settings.darkTheme
+ : legacyTheme && legacyTheme !== DEFAULT_DARK_THEME && !hasNewThemePreferences
+ ? legacyTheme
+ : DEFAULT_DARK_THEME;
+ const nextLightTheme: Theme = typeof settings.lightTheme === 'string'
+ ? settings.lightTheme
+ : legacyTheme && legacyTheme !== DEFAULT_DARK_THEME && !hasNewThemePreferences
+ ? legacyTheme
+ : DEFAULT_LIGHT_THEME;
+
+ setAppearanceModeState(nextAppearanceMode);
+ setDarkThemeState(nextDarkTheme);
+ setLightThemeState(nextLightTheme);
+ localStorage.setItem('appearanceMode', nextAppearanceMode);
+ localStorage.setItem('darkTheme', nextDarkTheme);
+ localStorage.setItem('lightTheme', nextLightTheme);
}
if (settings.language) {
@@ -1295,6 +1405,24 @@ export const SettingsProvider: React.FC = ({ children, ba
loadCustomThemes();
}, [loadCustomThemes]);
+ React.useEffect(() => {
+ if (typeof window === 'undefined' || !window.matchMedia) return;
+ const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
+ const handleChange = (event: MediaQueryListEvent) => {
+ setSystemIsDark(event.matches);
+ };
+
+ setSystemIsDark(mediaQuery.matches);
+ mediaQuery.addEventListener('change', handleChange);
+ return () => {
+ mediaQuery.removeEventListener('change', handleChange);
+ };
+ }, []);
+
+ React.useEffect(() => {
+ applyAppearancePreferences(appearanceMode, darkTheme, lightTheme, systemIsDark);
+ }, [appearanceMode, applyAppearancePreferences, darkTheme, lightTheme, systemIsDark]);
+
// Load mute preferences on mount (server-side, requires auth)
React.useEffect(() => {
loadMutePreferences();
@@ -1304,7 +1432,7 @@ export const SettingsProvider: React.FC = ({ children, ba
React.useEffect(() => {
logger.debug(`🔄 useEffect triggered - customThemes: ${customThemes.length}, theme: ${theme}`);
if (customThemes.length > 0 && theme) {
- const isBuiltIn = builtInThemes.includes(theme as BuiltInTheme);
+ const isBuiltIn = BUILT_IN_THEMES.includes(theme as BuiltInTheme);
logger.debug(`📝 useEffect - Is built-in: ${isBuiltIn}`);
if (!isBuiltIn) {
@@ -1342,6 +1470,9 @@ export const SettingsProvider: React.FC = ({ children, ba
defaultMapCenterZoom,
defaultLandingPage,
theme,
+ appearanceMode,
+ darkTheme,
+ lightTheme,
language,
customThemes,
customTilesets,
@@ -1384,6 +1515,9 @@ export const SettingsProvider: React.FC = ({ children, ba
setDefaultMapCenterZoom,
setDefaultLandingPage,
setTheme,
+ setAppearanceMode,
+ setDarkTheme,
+ setLightTheme,
setLanguage,
loadCustomThemes,
addCustomTileset,
@@ -1436,6 +1570,9 @@ export const useDisplaySettings = () => {
dateFormat: s.dateFormat, setDateFormat: s.setDateFormat,
language: s.language, setLanguage: s.setLanguage,
theme: s.theme, setTheme: s.setTheme,
+ appearanceMode: s.appearanceMode, setAppearanceMode: s.setAppearanceMode,
+ darkTheme: s.darkTheme, setDarkTheme: s.setDarkTheme,
+ lightTheme: s.lightTheme, setLightTheme: s.setLightTheme,
customThemes: s.customThemes, isLoadingThemes: s.isLoadingThemes, loadCustomThemes: s.loadCustomThemes,
};
};
diff --git a/src/server/constants/settings.ts b/src/server/constants/settings.ts
index 545032a8d..2be4bb8b2 100644
--- a/src/server/constants/settings.ts
+++ b/src/server/constants/settings.ts
@@ -80,6 +80,9 @@ export const VALID_SETTINGS_KEYS = [
'mapPinStyle',
'favoriteTelemetryStorageDays',
'theme',
+ 'appearanceMode',
+ 'darkTheme',
+ 'lightTheme',
'customTilesets',
'hideIncompleteNodes',
'inactiveNodeThresholdHours',