From a57698249738b7773e18b3ba7b9a2602526a3d44 Mon Sep 17 00:00:00 2001 From: Yunus M Date: Wed, 23 Jul 2025 01:43:07 +0530 Subject: [PATCH] feat: update app loading screen and add system theme option (#8567) * feat: update app loading screen and add system theme option * feat: update test case --- frontend/src/AppRoutes/index.tsx | 3 +- .../AppLoading/AppLoading.styles.scss | 152 ++++++++++++++++ .../src/components/AppLoading/AppLoading.tsx | 50 ++++++ .../AppLoading/__tests__/AppLoading.test.tsx | 76 ++++++++ frontend/src/constants/localStorage.ts | 1 + .../MySettings/MySettings.styles.scss | 35 ++++ .../MySettings/__tests__/MySettings.test.tsx | 11 +- frontend/src/container/MySettings/index.tsx | 46 ++++- .../__tests__/useDarkMode.test.tsx | 169 ++++++++++++++++++ frontend/src/hooks/useDarkMode/index.tsx | 60 ++++++- frontend/src/styles.scss | 19 ++ 11 files changed, 608 insertions(+), 14 deletions(-) create mode 100644 frontend/src/components/AppLoading/AppLoading.styles.scss create mode 100644 frontend/src/components/AppLoading/AppLoading.tsx create mode 100644 frontend/src/components/AppLoading/__tests__/AppLoading.test.tsx create mode 100644 frontend/src/hooks/useDarkMode/__tests__/useDarkMode.test.tsx diff --git a/frontend/src/AppRoutes/index.tsx b/frontend/src/AppRoutes/index.tsx index a7338775f07a..63ffcde4cd8f 100644 --- a/frontend/src/AppRoutes/index.tsx +++ b/frontend/src/AppRoutes/index.tsx @@ -3,6 +3,7 @@ import { ConfigProvider } from 'antd'; import getLocalStorageApi from 'api/browser/localstorage/get'; import setLocalStorageApi from 'api/browser/localstorage/set'; import logEvent from 'api/common/logEvent'; +import AppLoading from 'components/AppLoading/AppLoading'; import NotFound from 'components/NotFound'; import Spinner from 'components/Spinner'; import UserpilotRouteTracker from 'components/UserpilotRouteTracker/UserpilotRouteTracker'; @@ -342,7 +343,7 @@ function App(): JSX.Element { if (isLoggedInState) { // if the setup calls are loading then return a spinner if (isFetchingActiveLicense || isFetchingUser || isFetchingFeatureFlags) { - return ; + return ; } // if the required calls fails then return a something went wrong error diff --git a/frontend/src/components/AppLoading/AppLoading.styles.scss b/frontend/src/components/AppLoading/AppLoading.styles.scss new file mode 100644 index 000000000000..18c12aa213c4 --- /dev/null +++ b/frontend/src/components/AppLoading/AppLoading.styles.scss @@ -0,0 +1,152 @@ +.app-loading-container { + display: flex; + justify-content: center; + align-items: center; + height: 100vh; + background-color: var(--bg-ink-400, #121317); // Dark theme background + + .app-loading-content { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + height: 100%; + + .brand { + display: flex; + align-items: center; + justify-content: center; + gap: 12px; + + margin-bottom: 12px; + + .brand-logo { + width: 40px; + height: 40px; + } + + .brand-title { + font-size: 20px; + font-weight: 600; + color: var(--bg-vanilla-100, #ffffff); // White text for dark theme + margin: 0; + } + } + + .brand-tagline { + margin-bottom: 24px; + + .ant-typography { + color: var(--bg-vanilla-400, #c0c1c3); // Light gray text for dark theme + } + } + + /* HTML:
*/ + .loader { + width: 150px; + height: 12px; + border-radius: 2px; + color: var(--bg-robin-500, #4e74f8); // Primary blue color + border: 2px solid; + position: relative; + } + .loader::before { + content: ''; + position: absolute; + margin: 2px; + inset: 0 100% 0 0; + border-radius: inherit; + background: currentColor; + animation: l6 2s infinite; + } + @keyframes l6 { + 100% { + inset: 0; + } + } + } +} + +// Light theme styles - more specific selector +.app-loading-container.lightMode { + background-color: var( + --bg-vanilla-100, + #ffffff + ) !important; // White background for light theme + + .app-loading-content { + .brand { + .brand-title { + color: var(--bg-ink-400, #121317) !important; // Dark text for light theme + } + } + + .brand-tagline { + .ant-typography { + color: var( + --bg-ink-300, + #6b7280 + ) !important; // Dark gray text for light theme + } + } + + .loader { + color: var( + --bg-robin-500, + #4e74f8 + ) !important; // Keep primary blue color for consistency + } + } +} + +.perilin-bg { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + + background: radial-gradient(circle, #fff 10%, transparent 0); + background-size: 12px 12px; + opacity: 1; + + mask-image: radial-gradient( + circle at 50% 0, + rgba(11, 12, 14, 0.1) 0, + rgba(11, 12, 14, 0) 100% + ); + -webkit-mask-image: radial-gradient( + circle at 50% 0, + rgba(11, 12, 14, 0.1) 0, + rgba(11, 12, 14, 0) 100% + ); +} + +// Dark theme styles - ensure dark theme is properly applied +.app-loading-container.dark { + background-color: var(--bg-ink-400, #121317) !important; // Dark background + + .app-loading-content { + .brand { + .brand-title { + color: var( + --bg-vanilla-100, + #ffffff + ) !important; // White text for dark theme + } + } + + .brand-tagline { + .ant-typography { + color: var( + --bg-vanilla-400, + #c0c1c3 + ) !important; // Light gray text for dark theme + } + } + + .loader { + color: var(--bg-robin-500, #4e74f8) !important; // Primary blue color + } + } +} diff --git a/frontend/src/components/AppLoading/AppLoading.tsx b/frontend/src/components/AppLoading/AppLoading.tsx new file mode 100644 index 000000000000..79c487b45283 --- /dev/null +++ b/frontend/src/components/AppLoading/AppLoading.tsx @@ -0,0 +1,50 @@ +import './AppLoading.styles.scss'; + +import { Typography } from 'antd'; +import get from 'api/browser/localstorage/get'; +import { LOCALSTORAGE } from 'constants/localStorage'; +import { THEME_MODE } from 'hooks/useDarkMode/constant'; + +function AppLoading(): JSX.Element { + // Get theme from localStorage directly to avoid context dependency + const getThemeFromStorage = (): boolean => { + try { + const theme = get(LOCALSTORAGE.THEME); + return theme !== THEME_MODE.LIGHT; // Return true for dark, false for light + } catch (error) { + // If localStorage is not available, default to dark theme + return true; + } + }; + + const isDarkMode = getThemeFromStorage(); + + return ( +
+
+
+
+ SigNoz + + + SigNoz + +
+ +
+ + OpenTelemetry-Native Logs, Metrics and Traces in a single pane + +
+ +
+
+
+ ); +} + +export default AppLoading; diff --git a/frontend/src/components/AppLoading/__tests__/AppLoading.test.tsx b/frontend/src/components/AppLoading/__tests__/AppLoading.test.tsx new file mode 100644 index 000000000000..4e37ddb4591c --- /dev/null +++ b/frontend/src/components/AppLoading/__tests__/AppLoading.test.tsx @@ -0,0 +1,76 @@ +import { render, screen } from '@testing-library/react'; + +import AppLoading from '../AppLoading'; + +// Mock the localStorage API +const mockGet = jest.fn(); +jest.mock('api/browser/localstorage/get', () => ({ + __esModule: true, + default: mockGet, +})); + +describe('AppLoading', () => { + const SIGNOZ_TEXT = 'SigNoz'; + const TAGLINE_TEXT = + 'OpenTelemetry-Native Logs, Metrics and Traces in a single pane'; + const CONTAINER_SELECTOR = '.app-loading-container'; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('should render loading screen with dark theme by default', () => { + // Mock localStorage to return dark theme (or undefined for default) + mockGet.mockReturnValue(undefined); + + render(); + + // Check if main elements are rendered + expect(screen.getByAltText(SIGNOZ_TEXT)).toBeInTheDocument(); + expect(screen.getByText(SIGNOZ_TEXT)).toBeInTheDocument(); + expect(screen.getByText(TAGLINE_TEXT)).toBeInTheDocument(); + + // Check if dark theme class is applied + const container = screen.getByText(SIGNOZ_TEXT).closest(CONTAINER_SELECTOR); + expect(container).toHaveClass('dark'); + expect(container).not.toHaveClass('lightMode'); + }); + + it('should have proper structure and content', () => { + // Mock localStorage to return dark theme + mockGet.mockReturnValue(undefined); + + render(); + + // Check for brand logo + const logo = screen.getByAltText(SIGNOZ_TEXT); + expect(logo).toBeInTheDocument(); + expect(logo).toHaveAttribute('src', '/Logos/signoz-brand-logo.svg'); + + // Check for brand title + const title = screen.getByText(SIGNOZ_TEXT); + expect(title).toBeInTheDocument(); + + // Check for tagline + const tagline = screen.getByText(TAGLINE_TEXT); + expect(tagline).toBeInTheDocument(); + + // Check for loader + const loader = document.querySelector('.loader'); + expect(loader).toBeInTheDocument(); + }); + + it('should handle localStorage errors gracefully', () => { + // Mock localStorage to throw an error + mockGet.mockImplementation(() => { + throw new Error('localStorage not available'); + }); + + render(); + + // Should still render with dark theme as fallback + expect(screen.getByText(SIGNOZ_TEXT)).toBeInTheDocument(); + const container = screen.getByText(SIGNOZ_TEXT).closest(CONTAINER_SELECTOR); + expect(container).toHaveClass('dark'); + }); +}); diff --git a/frontend/src/constants/localStorage.ts b/frontend/src/constants/localStorage.ts index 1511bf3b4b25..030b2d62b120 100644 --- a/frontend/src/constants/localStorage.ts +++ b/frontend/src/constants/localStorage.ts @@ -4,6 +4,7 @@ export enum LOCALSTORAGE { AUTH_TOKEN = 'AUTH_TOKEN', REFRESH_AUTH_TOKEN = 'REFRESH_AUTH_TOKEN', THEME = 'THEME', + THEME_AUTO_SWITCH = 'THEME_AUTO_SWITCH', LOGS_VIEW_MODE = 'LOGS_VIEW_MODE', LOGS_LINES_PER_ROW = 'LOGS_LINES_PER_ROW', LOGS_LIST_OPTIONS = 'LOGS_LIST_OPTIONS', diff --git a/frontend/src/container/MySettings/MySettings.styles.scss b/frontend/src/container/MySettings/MySettings.styles.scss index 975ad18dbef2..4ba17f2fe612 100644 --- a/frontend/src/container/MySettings/MySettings.styles.scss +++ b/frontend/src/container/MySettings/MySettings.styles.scss @@ -120,6 +120,28 @@ line-height: 20px; /* 142.857% */ letter-spacing: -0.07px; } + + .auto-theme-info { + margin-top: 8px; + padding: 8px 12px; + border-radius: 4px; + background: var(--bg-slate-400, #1d212d); + border: 1px solid var(--bg-slate-500, #161922); + + .auto-theme-status { + color: var(--bg-vanilla-400, #c0c1c3); + font-family: Inter; + font-size: 11px; + font-style: normal; + line-height: 16px; + letter-spacing: -0.07px; + + strong { + color: var(--bg-robin-400, #4e74f8); + font-weight: 600; + } + } + } } } } @@ -168,6 +190,19 @@ .user-preference-section-content-item-description { color: var(--bg-ink-300); } + + .auto-theme-info { + background: var(--bg-vanilla-200); + border: 1px solid var(--bg-vanilla-300); + + .auto-theme-status { + color: var(--bg-ink-300); + + strong { + color: var(--bg-robin-500); + } + } + } } } } diff --git a/frontend/src/container/MySettings/__tests__/MySettings.test.tsx b/frontend/src/container/MySettings/__tests__/MySettings.test.tsx index 929abd381889..2802ea7e2d6a 100644 --- a/frontend/src/container/MySettings/__tests__/MySettings.test.tsx +++ b/frontend/src/container/MySettings/__tests__/MySettings.test.tsx @@ -7,8 +7,11 @@ const logEventFunction = jest.fn(); jest.mock('hooks/useDarkMode', () => ({ __esModule: true, useIsDarkMode: jest.fn(() => true), + useSystemTheme: jest.fn(() => 'dark'), default: jest.fn(() => ({ toggleTheme: toggleThemeFunction, + autoSwitch: false, + setAutoSwitch: jest.fn(), })), })); @@ -45,7 +48,7 @@ describe('MySettings Flows', () => { }); describe('Dark/Light Theme Switch', () => { - it('Should display Dark and Light theme options properly', async () => { + it('Should display Dark, Light, and System theme options properly', async () => { // Check Dark theme option expect(screen.getByText('Dark')).toBeInTheDocument(); const darkThemeIcon = screen.getByTestId('dark-theme-icon'); @@ -58,6 +61,12 @@ describe('MySettings Flows', () => { expect(lightThemeIcon).toBeInTheDocument(); expect(lightThemeIcon.tagName).toBe('svg'); expect(screen.getByText('Beta')).toBeInTheDocument(); + + // Check System theme option + expect(screen.getByText('System')).toBeInTheDocument(); + const autoThemeIcon = screen.getByTestId('auto-theme-icon'); + expect(autoThemeIcon).toBeInTheDocument(); + expect(autoThemeIcon.tagName).toBe('svg'); }); it('Should have Dark theme selected by default', async () => { diff --git a/frontend/src/container/MySettings/index.tsx b/frontend/src/container/MySettings/index.tsx index b563d554dfe5..4fd4d2b163bf 100644 --- a/frontend/src/container/MySettings/index.tsx +++ b/frontend/src/container/MySettings/index.tsx @@ -5,9 +5,9 @@ import logEvent from 'api/common/logEvent'; import updateUserPreference from 'api/v1/user/preferences/name/update'; import { AxiosError } from 'axios'; import { USER_PREFERENCES } from 'constants/userPreferences'; -import useThemeMode, { useIsDarkMode } from 'hooks/useDarkMode'; +import useThemeMode, { useIsDarkMode, useSystemTheme } from 'hooks/useDarkMode'; import { useNotifications } from 'hooks/useNotifications'; -import { Moon, Sun } from 'lucide-react'; +import { MonitorCog, Moon, Sun } from 'lucide-react'; import { useAppContext } from 'providers/App/App'; import { useEffect, useState } from 'react'; import { useMutation } from 'react-query'; @@ -19,8 +19,9 @@ import UserInfo from './UserInfo'; function MySettings(): JSX.Element { const isDarkMode = useIsDarkMode(); - const { toggleTheme } = useThemeMode(); const { userPreferences, updateUserPreferenceInContext } = useAppContext(); + const { toggleTheme, autoSwitch, setAutoSwitch } = useThemeMode(); + const systemTheme = useSystemTheme(); const { notifications } = useNotifications(); const [sideNavPinned, setSideNavPinned] = useState(false); @@ -68,16 +69,37 @@ function MySettings(): JSX.Element { ), value: 'light', }, + { + label: ( +
+ System{' '} +
+ ), + value: 'auto', + }, ]; - const [theme, setTheme] = useState(isDarkMode ? 'dark' : 'light'); + const [theme, setTheme] = useState(() => { + if (autoSwitch) return 'auto'; + return isDarkMode ? 'dark' : 'light'; + }); const handleThemeChange = ({ target: { value } }: RadioChangeEvent): void => { logEvent('Account Settings: Theme Changed', { theme: value, }); setTheme(value); - toggleTheme(); + + if (value === 'auto') { + setAutoSwitch(true); + } else { + setAutoSwitch(false); + // Only toggle if the current theme is different from the target + const targetIsDark = value === 'dark'; + if (targetIsDark !== isDarkMode) { + toggleTheme(); + } + } }; const handleSideNavPinnedChange = (checked: boolean): void => { @@ -150,13 +172,23 @@ function MySettings(): JSX.Element { optionType="button" buttonStyle="solid" data-testid="theme-selector" - size="small" + size="middle" />
- Select if SigNoz's appearance should be light or dark + Select if SigNoz's appearance should be light, dark, or + automatically follow your system preference
+ + {autoSwitch && ( +
+
+ Currently following system theme:{' '} + {systemTheme === 'dark' ? 'Dark' : 'Light'} +
+
+ )}
diff --git a/frontend/src/hooks/useDarkMode/__tests__/useDarkMode.test.tsx b/frontend/src/hooks/useDarkMode/__tests__/useDarkMode.test.tsx new file mode 100644 index 000000000000..779d93b01194 --- /dev/null +++ b/frontend/src/hooks/useDarkMode/__tests__/useDarkMode.test.tsx @@ -0,0 +1,169 @@ +import { act, renderHook } from '@testing-library/react'; + +import { + ThemeProvider, + useIsDarkMode, + useSystemTheme, + useThemeMode, +} from '../index'; + +// Mock localStorage +const localStorageMock = { + getItem: jest.fn(), + setItem: jest.fn(), + clear: jest.fn(), +}; +Object.defineProperty(window, 'localStorage', { + value: localStorageMock, +}); + +// Helper function to create matchMedia mock +const createMatchMediaMock = (prefersDark: boolean): jest.Mock => + jest.fn().mockImplementation((query: string) => ({ + matches: + query === '(prefers-color-scheme: dark)' ? prefersDark : !prefersDark, + media: query, + onchange: null, + addListener: jest.fn(), + removeListener: jest.fn(), + addEventListener: jest.fn(), + removeEventListener: jest.fn(), + dispatchEvent: jest.fn(), + })); + +// Mock matchMedia +Object.defineProperty(window, 'matchMedia', { + writable: true, + value: createMatchMediaMock(true), // Default to dark theme +}); + +describe('useDarkMode', () => { + beforeEach(() => { + jest.clearAllMocks(); + localStorageMock.getItem.mockReturnValue(null); + }); + + describe('useSystemTheme', () => { + it('should return dark theme by default', () => { + const { result } = renderHook(() => useSystemTheme()); + expect(result.current).toBe('dark'); + }); + + it('should return light theme when system prefers light', () => { + Object.defineProperty(window, 'matchMedia', { + writable: true, + value: createMatchMediaMock(false), // Light theme + }); + + const { result } = renderHook(() => useSystemTheme()); + expect(result.current).toBe('light'); + }); + }); + + describe('ThemeProvider', () => { + it('should provide theme context with default values', () => { + const wrapper = ({ + children, + }: { + children: React.ReactNode; + }): JSX.Element => {children}; + + const { result } = renderHook(() => useThemeMode(), { wrapper }); + + expect(result.current.theme).toBe('dark'); + expect(typeof result.current.toggleTheme).toBe('function'); + expect(result.current.autoSwitch).toBe(false); + expect(typeof result.current.setAutoSwitch).toBe('function'); + }); + + it('should load theme from localStorage', () => { + localStorageMock.getItem.mockImplementation((key: string) => { + if (key === 'THEME') return 'light'; + if (key === 'THEME_AUTO_SWITCH') return 'true'; + return null; + }); + + const wrapper = ({ + children, + }: { + children: React.ReactNode; + }): JSX.Element => {children}; + + const { result } = renderHook(() => useThemeMode(), { wrapper }); + + expect(result.current.theme).toBe('light'); + expect(result.current.autoSwitch).toBe(true); + }); + + it('should toggle theme correctly', () => { + const wrapper = ({ + children, + }: { + children: React.ReactNode; + }): JSX.Element => {children}; + + const { result } = renderHook(() => useThemeMode(), { wrapper }); + + act(() => { + result.current.toggleTheme(); + }); + + expect(result.current.theme).toBe('light'); + expect(localStorageMock.setItem).toHaveBeenCalledWith('THEME', 'light'); + }); + + it('should handle auto-switch functionality', () => { + // Mock system theme as light + Object.defineProperty(window, 'matchMedia', { + writable: true, + value: createMatchMediaMock(false), // Light theme + }); + + const wrapper = ({ + children, + }: { + children: React.ReactNode; + }): JSX.Element => {children}; + + const { result } = renderHook(() => useThemeMode(), { wrapper }); + + act(() => { + result.current.setAutoSwitch(true); + }); + + expect(result.current.autoSwitch).toBe(true); + expect(localStorageMock.setItem).toHaveBeenCalledWith( + 'THEME_AUTO_SWITCH', + 'true', + ); + }); + }); + + describe('useIsDarkMode', () => { + it('should return true for dark theme', () => { + localStorageMock.getItem.mockReturnValue('dark'); + + const wrapper = ({ + children, + }: { + children: React.ReactNode; + }): JSX.Element => {children}; + + const { result } = renderHook(() => useIsDarkMode(), { wrapper }); + expect(result.current).toBe(true); + }); + + it('should return false for light theme', () => { + localStorageMock.getItem.mockReturnValue('light'); + + const wrapper = ({ + children, + }: { + children: React.ReactNode; + }): JSX.Element => {children}; + + const { result } = renderHook(() => useIsDarkMode(), { wrapper }); + expect(result.current).toBe(false); + }); + }); +}); diff --git a/frontend/src/hooks/useDarkMode/index.tsx b/frontend/src/hooks/useDarkMode/index.tsx index 5b6d0960045f..eb9299929dda 100644 --- a/frontend/src/hooks/useDarkMode/index.tsx +++ b/frontend/src/hooks/useDarkMode/index.tsx @@ -5,9 +5,12 @@ import set from 'api/browser/localstorage/set'; import { LOCALSTORAGE } from 'constants/localStorage'; import { createContext, + Dispatch, ReactNode, + SetStateAction, useCallback, useContext, + useEffect, useMemo, useState, } from 'react'; @@ -16,13 +19,54 @@ import { THEME_MODE } from './constant'; export const ThemeContext = createContext({ theme: THEME_MODE.DARK, - toggleTheme: () => {}, + toggleTheme: (): void => {}, + autoSwitch: false, + setAutoSwitch: ((): void => {}) as Dispatch>, }); +// Hook to detect system theme preference +export const useSystemTheme = (): 'light' | 'dark' => { + const [systemTheme, setSystemTheme] = useState<'light' | 'dark'>('dark'); + + useEffect(() => { + const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)'); + setSystemTheme(mediaQuery.matches ? 'dark' : 'light'); + + const handler = (e: MediaQueryListEvent): void => { + setSystemTheme(e.matches ? 'dark' : 'light'); + }; + + mediaQuery.addEventListener('change', handler); + return (): void => mediaQuery.removeEventListener('change', handler); + }, []); + + return systemTheme; +}; + export function ThemeProvider({ children }: ThemeProviderProps): JSX.Element { const [theme, setTheme] = useState(get(LOCALSTORAGE.THEME) || THEME_MODE.DARK); + const [autoSwitch, setAutoSwitch] = useState( + get(LOCALSTORAGE.THEME_AUTO_SWITCH) === 'true', + ); + const systemTheme = useSystemTheme(); - const toggleTheme = useCallback(() => { + // Handle auto-switch functionality + useEffect(() => { + if (autoSwitch) { + const newTheme = systemTheme === 'dark' ? THEME_MODE.DARK : THEME_MODE.LIGHT; + if (newTheme !== theme) { + setTheme(newTheme); + set(LOCALSTORAGE.THEME, newTheme); + } + } + }, [systemTheme, autoSwitch, theme]); + + // Save auto-switch preference + useEffect(() => { + set(LOCALSTORAGE.THEME_AUTO_SWITCH, autoSwitch.toString()); + }, [autoSwitch]); + + const toggleTheme = useCallback((): void => { if (theme === THEME_MODE.LIGHT) { setTheme(THEME_MODE.DARK); set(LOCALSTORAGE.THEME, THEME_MODE.DARK); @@ -37,8 +81,10 @@ export function ThemeProvider({ children }: ThemeProviderProps): JSX.Element { () => ({ theme, toggleTheme, + autoSwitch, + setAutoSwitch, }), - [theme, toggleTheme], + [theme, toggleTheme, autoSwitch, setAutoSwitch], ); return {children}; @@ -51,12 +97,16 @@ interface ThemeProviderProps { interface ThemeMode { theme: string; toggleTheme: () => void; + autoSwitch: boolean; + setAutoSwitch: Dispatch>; } export const useThemeMode = (): ThemeMode => { - const { theme, toggleTheme } = useContext(ThemeContext); + const { theme, toggleTheme, autoSwitch, setAutoSwitch } = useContext( + ThemeContext, + ); - return { theme, toggleTheme }; + return { theme, toggleTheme, autoSwitch, setAutoSwitch }; }; export const useIsDarkMode = (): boolean => { diff --git a/frontend/src/styles.scss b/frontend/src/styles.scss index 9c6fdf7ad61c..159620df1f0d 100644 --- a/frontend/src/styles.scss +++ b/frontend/src/styles.scss @@ -16,6 +16,25 @@ body { box-sizing: border-box; } +// Theme transition animations +* { + transition: background-color 0.3s ease, color 0.3s ease, border-color 0.3s ease, + box-shadow 0.3s ease; +} + +// For components that shouldn't transition (like loading spinners, animations) +.no-transition, +.no-transition * { + transition: none !important; +} + +// Respect user's reduced motion preference +@media (prefers-reduced-motion: reduce) { + * { + transition: none !important; + } +} + .u-legend { max-height: 30px; // Default height for backward compatibility overflow-y: auto;