From 70d5b38a81ebeea1c1a065e6c49943520f0d9e6e Mon Sep 17 00:00:00 2001 From: Eli Bosley Date: Tue, 24 Mar 2026 15:40:25 -0400 Subject: [PATCH] fix(theme): reconcile dark root class on bootstrap - Purpose: ensure light themes do not inherit a stale `dark` class on initial page load.\n- Before: theme bootstrap trusted any existing root/body `dark` class before reconciling against the authoritative DOM theme name.\n- Problem: a stale client-side class could survive into a light-theme render and keep Tailwind dark styles active.\n- Change: make `--theme-name` authoritative during store initialization and only fall back to class-based dark-mode bootstrap when no theme name is present.\n- Coverage: add a regression test that starts with a light DOM theme plus stale `dark` classes and verifies they are removed. --- web/__test__/store/theme.test.ts | 12 ++++++++++++ web/src/store/theme.ts | 22 +++++++++++----------- 2 files changed, 23 insertions(+), 11 deletions(-) diff --git a/web/__test__/store/theme.test.ts b/web/__test__/store/theme.test.ts index e9cb7246ad..f301ac2f2d 100644 --- a/web/__test__/store/theme.test.ts +++ b/web/__test__/store/theme.test.ts @@ -286,5 +286,17 @@ describe('Theme Store', () => { vi.restoreAllMocks(); }); + + it('should remove stale dark classes when the DOM theme is light', () => { + document.documentElement.style.setProperty('--theme-name', 'white'); + originalDocumentElementAddClass.call(document.documentElement.classList, 'dark'); + originalAddClassFn.call(document.body.classList, 'dark'); + + const store = createStore(); + + expect(document.documentElement.classList.remove).toHaveBeenCalledWith('dark'); + expect(document.body.classList.remove).toHaveBeenCalledWith('dark'); + expect(store.darkMode).toBe(false); + }); }); }); diff --git a/web/src/store/theme.ts b/web/src/store/theme.ts index a3d2f5a599..cd0f6178ef 100644 --- a/web/src/store/theme.ts +++ b/web/src/store/theme.ts @@ -48,6 +48,9 @@ const getCssVar = (name: string): string => { const readDomThemeName = () => getCssVar('--theme-name'); +const isDarkThemeName = (themeName: string) => + DARK_UI_THEMES.includes(themeName as (typeof DARK_UI_THEMES)[number]); + const syncDarkClass = (method: 'add' | 'remove') => { if (!isDomAvailable()) return; document.documentElement.classList[method]('dark'); @@ -98,12 +101,6 @@ export const useThemeStore = defineStore('theme', () => { const devOverride = ref(false); const darkMode = ref(false); - // Initialize dark mode from CSS variable set by PHP or any pre-applied .dark class - if (isDomAvailable()) { - darkMode.value = isDarkModeActive(); - bootstrapDarkClass(darkMode); - } - // Lazy query - only executes when explicitly called const { load, onResult, onError } = useLazyQuery(GET_THEME_QUERY, null, { fetchPolicy: 'cache-and-network', @@ -208,16 +205,19 @@ export const useThemeStore = defineStore('theme', () => { watch( () => theme.value.name, (themeName) => { - const isDark = DARK_UI_THEMES.includes(themeName as (typeof DARK_UI_THEMES)[number]); - applyDarkClass(isDark, darkMode); + applyDarkClass(isDarkThemeName(themeName), darkMode); }, { immediate: false } ); // Initialize theme from DOM on store creation - const domThemeName = themeName.value; - if (domThemeName && domThemeName !== DEFAULT_THEME.name) { - theme.value.name = domThemeName; + const domThemeName = readDomThemeName(); + if (domThemeName) { + setTheme({ name: domThemeName }); + applyDarkClass(isDarkThemeName(domThemeName), darkMode); + } else if (isDomAvailable()) { + darkMode.value = isDarkModeActive(); + bootstrapDarkClass(darkMode); } return {