diff --git a/client/jest.setup.js b/client/jest.setup.js
index ccc7ab57a7..c075aa4eb8 100644
--- a/client/jest.setup.js
+++ b/client/jest.setup.js
@@ -5,3 +5,35 @@ import 'regenerator-runtime/runtime';
// See: https://github.com/testing-library/jest-dom
// eslint-disable-next-line import/no-extraneous-dependencies
import '@testing-library/jest-dom';
+
+// Mock matchMedia
+window.matchMedia = jest.fn().mockImplementation((query) => ({
+ matches: false,
+ media: query,
+ onchange: null,
+ addListener: jest.fn(), // Deprecated
+ removeListener: jest.fn(), // Deprecated
+ addEventListener: jest.fn(),
+ removeEventListener: jest.fn(),
+ dispatchEvent: jest.fn()
+}));
+
+// Mock localStorage
+const localStorageMock = (function () {
+ let store = {};
+ return {
+ getItem: jest.fn((key) => store[key] || null),
+ setItem: jest.fn((key, value) => {
+ store[key] = value.toString();
+ }),
+ removeItem: jest.fn((key) => {
+ delete store[key];
+ }),
+ clear: jest.fn(() => {
+ store = {};
+ })
+ };
+})();
+Object.defineProperty(window, 'localStorage', {
+ value: localStorageMock
+});
diff --git a/client/modules/App/components/ThemeProvider.jsx b/client/modules/App/components/ThemeProvider.jsx
index 8bbef6931e..b6d450099b 100644
--- a/client/modules/App/components/ThemeProvider.jsx
+++ b/client/modules/App/components/ThemeProvider.jsx
@@ -1,11 +1,62 @@
-import React from 'react';
+import React, { useEffect } from 'react';
import PropTypes from 'prop-types';
-import { useSelector } from 'react-redux';
+import { useSelector, useDispatch } from 'react-redux';
import { ThemeProvider } from 'styled-components';
-import theme from '../../../theme';
+import theme, { Theme } from '../../../theme';
+import { setTheme } from '../../IDE/actions/preferences';
const Provider = ({ children }) => {
const currentTheme = useSelector((state) => state.preferences.theme);
+ const dispatch = useDispatch();
+
+ // Detect system color scheme preference on initial load
+ useEffect(() => {
+ // Only apply system preference if the user hasn't explicitly set a theme
+ const userHasExplicitlySetTheme =
+ localStorage.getItem('has_set_theme') === 'true';
+ if (!userHasExplicitlySetTheme) {
+ const prefersDarkMode =
+ window.matchMedia &&
+ window.matchMedia('(prefers-color-scheme: dark)').matches;
+
+ if (prefersDarkMode) {
+ dispatch(setTheme(Theme.dark, { isSystemPreference: true }));
+ } else {
+ dispatch(setTheme(Theme.light, { isSystemPreference: true }));
+ }
+ }
+
+ // Listen for changes to system color scheme preference
+ const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
+
+ const handleChange = (e) => {
+ if (localStorage.getItem('has_set_theme') !== 'true') {
+ dispatch(
+ setTheme(e.matches ? Theme.dark : Theme.light, {
+ isSystemPreference: true
+ })
+ );
+ }
+ };
+
+ // Add event listener with modern API if available
+ if (mediaQuery.addEventListener) {
+ mediaQuery.addEventListener('change', handleChange);
+ } else {
+ // Fallback for older browsers
+ mediaQuery.addListener(handleChange);
+ }
+
+ // Clean up event listener
+ return () => {
+ if (mediaQuery.removeEventListener) {
+ mediaQuery.removeEventListener('change', handleChange);
+ } else {
+ mediaQuery.removeListener(handleChange);
+ }
+ };
+ }, [dispatch]);
+
return (
{children}
);
diff --git a/client/modules/IDE/actions/preferences.js b/client/modules/IDE/actions/preferences.js
index ebaefd1625..1658597bcf 100644
--- a/client/modules/IDE/actions/preferences.js
+++ b/client/modules/IDE/actions/preferences.js
@@ -184,7 +184,7 @@ export function setGridOutput(value) {
};
}
-export function setTheme(value) {
+export function setTheme(value, { isSystemPreference = false } = {}) {
// return {
// type: ActionTypes.SET_THEME,
// value
@@ -194,6 +194,13 @@ export function setTheme(value) {
type: ActionTypes.SET_THEME,
value
});
+
+ // If this is a user-initiated theme change (not from system preference),
+ // mark that the user has explicitly set a theme
+ if (!isSystemPreference) {
+ localStorage.setItem('has_set_theme', 'true');
+ }
+
const state = getState();
if (state.user.authenticated) {
const formParams = {
diff --git a/client/modules/IDE/components/Preferences/Preferences.unit.test.jsx b/client/modules/IDE/components/Preferences/Preferences.unit.test.jsx
index 55f9827d23..6f6e615e15 100644
--- a/client/modules/IDE/components/Preferences/Preferences.unit.test.jsx
+++ b/client/modules/IDE/components/Preferences/Preferences.unit.test.jsx
@@ -256,9 +256,27 @@ describe('', () => {
};
describe('testing theme switching', () => {
+ beforeEach(() => {
+ // Mock localStorage for theme tests
+ Object.defineProperty(window, 'localStorage', {
+ value: {
+ getItem: jest.fn().mockImplementation((key) => {
+ if (key === 'has_set_theme') return 'true';
+ return null;
+ }),
+ setItem: jest.fn(),
+ removeItem: jest.fn()
+ },
+ writable: true
+ });
+ });
+
describe('dark mode', () => {
it('switch to light', () => {
- subject({ theme: 'dark' });
+ const { store } = subject({ theme: 'dark' });
+
+ // Ensure the theme is actually set to dark in the Redux store
+ expect(store.getState().preferences.theme).toBe('dark');
const themeRadioCurrent = screen.getByRole('radio', {
name: /dark theme on/i
diff --git a/client/modules/IDE/components/Preferences/index.jsx b/client/modules/IDE/components/Preferences/index.jsx
index 3a21cca8d0..250e660643 100644
--- a/client/modules/IDE/components/Preferences/index.jsx
+++ b/client/modules/IDE/components/Preferences/index.jsx
@@ -172,6 +172,29 @@ export default function Preferences() {
{t('Preferences.Theme')}