From e6397e94e70a9262a0ad07adf16d3842b832ea6d Mon Sep 17 00:00:00 2001 From: Eli Bosley Date: Thu, 13 Nov 2025 15:03:58 -0500 Subject: [PATCH 1/5] Simplify theme persistence with plugin hook --- pnpm-lock.yaml | 24 ++++ web/__test__/store/theme.test.ts | 86 ++++++++++- web/package.json | 1 + .../DevThemeSwitcher.standalone.vue | 4 +- web/src/store/theme.ts | 133 +++++++++++++++--- 5 files changed, 220 insertions(+), 28 deletions(-) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0cbbf278d3..8db98eb3a4 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1154,6 +1154,9 @@ importers: pinia: specifier: 3.0.3 version: 3.0.3(typescript@5.9.2)(vue@3.5.20(typescript@5.9.2)) + pinia-plugin-persistedstate: + specifier: 4.7.1 + version: 4.7.1(@nuxt/kit@4.0.3(magicast@0.3.5))(pinia@3.0.3(typescript@5.9.2)(vue@3.5.20(typescript@5.9.2))) postcss-import: specifier: 16.1.1 version: 16.1.1(postcss@8.5.6) @@ -10116,6 +10119,20 @@ packages: resolution: {integrity: sha512-KocF8ve28eFjjuBKKGvzOBGzG8ew2OqOOSxTTZhirkzH7h3BI1vyzqlR0qbfcDBve1Yzo3FVlWUAtCRrbVN8Fw==} engines: {node: '>=14.16'} + pinia-plugin-persistedstate@4.7.1: + resolution: {integrity: sha512-WHOqh2esDlR3eAaknPbqXrkkj0D24h8shrDPqysgCFR6ghqP/fpFfJmMPJp0gETHsvrh9YNNg6dQfo2OEtDnIQ==} + peerDependencies: + '@nuxt/kit': '>=3.0.0' + '@pinia/nuxt': '>=0.10.0' + pinia: '>=3.0.0' + peerDependenciesMeta: + '@nuxt/kit': + optional: true + '@pinia/nuxt': + optional: true + pinia: + optional: true + pinia@3.0.3: resolution: {integrity: sha512-ttXO/InUULUXkMHpTdp9Fj4hLpD/2AoJdmAbAeW2yu1iy1k+pkFekQXw5VpC0/5p51IOR/jDaDRfRWRnMMsGOA==} peerDependencies: @@ -22744,6 +22761,13 @@ snapshots: pify@6.1.0: {} + pinia-plugin-persistedstate@4.7.1(@nuxt/kit@4.0.3(magicast@0.3.5))(pinia@3.0.3(typescript@5.9.2)(vue@3.5.20(typescript@5.9.2))): + dependencies: + defu: 6.1.4 + optionalDependencies: + '@nuxt/kit': 4.0.3(magicast@0.3.5) + pinia: 3.0.3(typescript@5.9.2)(vue@3.5.20(typescript@5.9.2)) + pinia@3.0.3(typescript@5.9.2)(vue@3.5.20(typescript@5.9.2)): dependencies: '@vue/devtools-api': 7.7.2 diff --git a/web/__test__/store/theme.test.ts b/web/__test__/store/theme.test.ts index 7e15402faf..a3ebcb9a23 100644 --- a/web/__test__/store/theme.test.ts +++ b/web/__test__/store/theme.test.ts @@ -3,13 +3,16 @@ */ import { nextTick, ref } from 'vue'; -import { createPinia, setActivePinia } from 'pinia'; +import { setActivePinia } from 'pinia'; import { defaultColors } from '~/themes/default'; import hexToRgba from 'hex-to-rgba'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { useThemeStore } from '~/store/theme'; +import type { Theme } from '~/themes/types'; + +import { globalPinia } from '~/store/globalPinia'; +import { THEME_STORAGE_KEY, useThemeStore } from '~/store/theme'; vi.mock('@vue/apollo-composable', () => ({ useQuery: () => ({ @@ -25,15 +28,20 @@ vi.mock('hex-to-rgba', () => ({ })); describe('Theme Store', () => { - let store: ReturnType; const originalAddClassFn = document.body.classList.add; const originalRemoveClassFn = document.body.classList.remove; const originalStyleCssText = document.body.style.cssText; const originalDocumentElementSetProperty = document.documentElement.style.setProperty; + const originalDocumentElementAddClass = document.documentElement.classList.add; + const originalDocumentElementRemoveClass = document.documentElement.classList.remove; + + let store: ReturnType | undefined; beforeEach(() => { - setActivePinia(createPinia()); - store = useThemeStore(); + setActivePinia(globalPinia); + store = undefined; + window.localStorage.clear(); + delete (globalPinia.state.value as Record).theme; document.body.classList.add = vi.fn(); document.body.classList.remove = vi.fn(); @@ -51,16 +59,32 @@ describe('Theme Store', () => { }); afterEach(() => { - // Restore original methods + store?.$dispose(); + store = undefined; + document.body.classList.add = originalAddClassFn; document.body.classList.remove = originalRemoveClassFn; document.body.style.cssText = originalStyleCssText; document.documentElement.style.setProperty = originalDocumentElementSetProperty; + document.documentElement.classList.add = originalDocumentElementAddClass; + document.documentElement.classList.remove = originalDocumentElementRemoveClass; vi.restoreAllMocks(); }); + const createStore = () => { + if (!store) { + store = useThemeStore(); + } + + return store; + }; + describe('State and Initialization', () => { it('should initialize with default theme', () => { + const store = createStore(); + + expect(typeof store.$persist).toBe('function'); + expect(store.theme).toEqual({ name: 'white', banner: false, @@ -74,6 +98,8 @@ describe('Theme Store', () => { }); it('should compute darkMode correctly', () => { + const store = createStore(); + expect(store.darkMode).toBe(false); store.setTheme({ ...store.theme, name: 'black' }); @@ -87,6 +113,8 @@ describe('Theme Store', () => { }); it('should compute bannerGradient correctly', () => { + const store = createStore(); + expect(store.bannerGradient).toBeUndefined(); store.setTheme({ @@ -112,6 +140,8 @@ describe('Theme Store', () => { describe('Actions', () => { it('should set theme correctly', () => { + const store = createStore(); + const newTheme = { name: 'black', banner: true, @@ -127,6 +157,8 @@ describe('Theme Store', () => { }); it('should update body classes for dark mode', async () => { + const store = createStore(); + store.setTheme({ ...store.theme, name: 'black' }); await nextTick(); @@ -141,6 +173,8 @@ describe('Theme Store', () => { }); it('should update activeColorVariables when theme changes', async () => { + const store = createStore(); + store.setTheme({ ...store.theme, name: 'white', @@ -170,6 +204,7 @@ describe('Theme Store', () => { }); it('should handle banner gradient correctly', async () => { + const store = createStore(); const mockHexToRgba = vi.mocked(hexToRgba); mockHexToRgba.mockClear(); @@ -200,5 +235,44 @@ describe('Theme Store', () => { 'linear-gradient(90deg, rgba(mock-#112233-0) 0, rgba(mock-#112233-0.7) 90%)' ); }); + + it('should hydrate theme from cache when available', () => { + const cachedTheme = { + name: 'black', + banner: true, + bannerGradient: false, + bgColor: '#222222', + descriptionShow: true, + metaColor: '#aaaaaa', + textColor: '#ffffff', + } satisfies Theme; + + window.localStorage.setItem(THEME_STORAGE_KEY, JSON.stringify({ theme: cachedTheme })); + + const store = createStore(); + + expect(store.theme).toEqual(cachedTheme); + }); + + it('should persist server theme responses to cache', async () => { + const store = createStore(); + + const serverTheme = { + name: 'gray', + banner: false, + bannerGradient: false, + bgColor: '#111111', + descriptionShow: false, + metaColor: '#999999', + textColor: '#eeeeee', + } satisfies Theme; + + store.setTheme(serverTheme, { source: 'server' }); + await nextTick(); + + expect(window.localStorage.getItem(THEME_STORAGE_KEY)).toEqual( + JSON.stringify({ theme: serverTheme }) + ); + }); }); }); diff --git a/web/package.json b/web/package.json index da27ec0638..ff7ef0b30e 100644 --- a/web/package.json +++ b/web/package.json @@ -132,6 +132,7 @@ "marked": "16.2.1", "marked-base-url": "1.1.7", "pinia": "3.0.3", + "pinia-plugin-persistedstate": "4.7.1", "postcss-import": "16.1.1", "semver": "7.7.2", "tailwind-merge": "2.6.0", diff --git a/web/src/components/DevThemeSwitcher.standalone.vue b/web/src/components/DevThemeSwitcher.standalone.vue index e2016f81c2..9c28dd7667 100644 --- a/web/src/components/DevThemeSwitcher.standalone.vue +++ b/web/src/components/DevThemeSwitcher.standalone.vue @@ -51,7 +51,7 @@ const updateTheme = (themeName: string, skipUrlUpdate = false) => { // ignore } - themeStore.setTheme({ name: themeName }, true); + themeStore.setTheme({ name: themeName }); themeStore.setCssVars(); const linkId = 'dev-theme-css-link'; @@ -100,7 +100,7 @@ onMounted(() => { if (!existingLink || !existingLink.href) { updateTheme(initialTheme, true); } else { - themeStore.setTheme({ name: initialTheme }, true); + themeStore.setTheme({ name: initialTheme }); themeStore.setCssVars(); } }); diff --git a/web/src/store/theme.ts b/web/src/store/theme.ts index c52658dee3..2898f5f32f 100644 --- a/web/src/store/theme.ts +++ b/web/src/store/theme.ts @@ -1,14 +1,17 @@ import { computed, ref, watch } from 'vue'; -import { defineStore } from 'pinia'; +import { defineStore, getActivePinia } from 'pinia'; import { useQuery } from '@vue/apollo-composable'; import { defaultColors } from '~/themes/default'; import hexToRgba from 'hex-to-rgba'; +import { createPersistedState } from 'pinia-plugin-persistedstate'; import type { GetThemeQuery } from '~/composables/gql/graphql'; import type { Theme, ThemeVariables } from '~/themes/types'; +import type { Pinia, PiniaPluginContext } from 'pinia'; import { graphql } from '~/composables/gql/gql'; +import { globalPinia } from '~/store/globalPinia'; // Themes that should apply the .dark class (dark UI themes) export const DARK_UI_THEMES = ['gray', 'black'] as const; @@ -27,6 +30,8 @@ export const GET_THEME_QUERY = graphql(` } `); +export const THEME_STORAGE_KEY = 'unraid.theme.publicTheme'; + const DEFAULT_THEME: Theme = { name: 'white', banner: false, @@ -37,6 +42,61 @@ const DEFAULT_THEME: Theme = { textColor: '', }; +type ThemeSource = 'local' | 'server'; + +type ThemeStorePersistedShape = { + setTheme: (data?: Partial, options?: { source?: ThemeSource }) => void; + theme: Theme; + $hydrate?: PiniaPluginContext['store']['$hydrate']; + $persist?: PiniaPluginContext['store']['$persist']; +}; + +const persistThemeState = createPersistedState(); + +const ensureThemePersistence = (store: ThemeStorePersistedShape, pinia: Pinia) => { + if (typeof store.$persist === 'function') { + return; + } + + const piniaWithApp = pinia as Pinia & { _a?: PiniaPluginContext['app'] }; + + persistThemeState({ + app: piniaWithApp._a, + pinia, + store: store as unknown as PiniaPluginContext['store'], + options: { + persist: { + key: THEME_STORAGE_KEY, + pick: ['theme'], + afterHydrate: ({ store: hydratedStore }) => { + const themeStore = hydratedStore as ThemeStorePersistedShape; + themeStore.setTheme(themeStore.theme); + }, + }, + }, + }); + + store.$hydrate?.({ runHooks: false }); +}; + +const sanitizeTheme = (data: Partial | null | undefined): Theme | null => { + if (!data || typeof data !== 'object') { + return null; + } + + return { + name: typeof data.name === 'string' ? data.name : DEFAULT_THEME.name, + banner: typeof data.banner === 'boolean' ? data.banner : DEFAULT_THEME.banner, + bannerGradient: + typeof data.bannerGradient === 'boolean' ? data.bannerGradient : DEFAULT_THEME.bannerGradient, + bgColor: typeof data.bgColor === 'string' ? data.bgColor : DEFAULT_THEME.bgColor, + descriptionShow: + typeof data.descriptionShow === 'boolean' ? data.descriptionShow : DEFAULT_THEME.descriptionShow, + metaColor: typeof data.metaColor === 'string' ? data.metaColor : DEFAULT_THEME.metaColor, + textColor: typeof data.textColor === 'string' ? data.textColor : DEFAULT_THEME.textColor, + }; +}; + const DYNAMIC_VAR_KEYS = [ '--custom-header-text-primary', '--custom-header-text-secondary', @@ -48,7 +108,7 @@ const DYNAMIC_VAR_KEYS = [ type DynamicVarKey = (typeof DYNAMIC_VAR_KEYS)[number]; -export const useThemeStore = defineStore('theme', () => { +const baseUseThemeStore = defineStore('theme', () => { // State const theme = ref({ ...DEFAULT_THEME }); @@ -61,21 +121,29 @@ export const useThemeStore = defineStore('theme', () => { nextFetchPolicy: 'cache-first', }); - const applyThemeFromQuery = (publicTheme?: GetThemeQuery['publicTheme'] | null) => { + const mapPublicTheme = (publicTheme?: GetThemeQuery['publicTheme'] | null): Theme | null => { if (!publicTheme) { + return null; + } + + return sanitizeTheme({ + name: publicTheme.name?.toLowerCase(), + banner: publicTheme.showBannerImage, + bannerGradient: publicTheme.showBannerGradient, + bgColor: publicTheme.headerBackgroundColor, + descriptionShow: publicTheme.showHeaderDescription, + metaColor: publicTheme.headerSecondaryTextColor, + textColor: publicTheme.headerPrimaryTextColor, + }); + }; + + const applyThemeFromQuery = (publicTheme?: GetThemeQuery['publicTheme'] | null) => { + const sanitized = mapPublicTheme(publicTheme); + if (!sanitized) { return; } - hasServerTheme.value = true; - theme.value = { - name: publicTheme.name?.toLowerCase() ?? DEFAULT_THEME.name, - banner: publicTheme.showBannerImage ?? DEFAULT_THEME.banner, - bannerGradient: publicTheme.showBannerGradient ?? DEFAULT_THEME.bannerGradient, - bgColor: publicTheme.headerBackgroundColor ?? DEFAULT_THEME.bgColor, - descriptionShow: publicTheme.showHeaderDescription ?? DEFAULT_THEME.descriptionShow, - metaColor: publicTheme.headerSecondaryTextColor ?? DEFAULT_THEME.metaColor, - textColor: publicTheme.headerPrimaryTextColor ?? DEFAULT_THEME.textColor, - }; + setTheme(sanitized, { source: 'server' }); }; onResult(({ data }) => { @@ -108,16 +176,24 @@ export const useThemeStore = defineStore('theme', () => { }); // Actions - const setTheme = (data?: Partial, force = false) => { + const setTheme = (data?: Partial, options: { source?: ThemeSource } = {}) => { if (data) { - if (hasServerTheme.value && !force && !devOverride.value) { + const { source = 'local' } = options; + + if (source === 'server') { + hasServerTheme.value = true; + } else if (hasServerTheme.value && !devOverride.value) { return; } - theme.value = { + const sanitized = sanitizeTheme({ ...theme.value, ...data, - }; + }); + + if (sanitized) { + theme.value = sanitized; + } } }; @@ -230,9 +306,13 @@ export const useThemeStore = defineStore('theme', () => { }); }; - watch(theme, () => { - setCssVars(); - }); + watch( + theme, + () => { + setCssVars(); + }, + { immediate: true } + ); return { // state @@ -246,3 +326,16 @@ export const useThemeStore = defineStore('theme', () => { setDevOverride, }; }); + +export const useThemeStore = ((pinia?: Pinia) => { + const resolved = pinia ?? getActivePinia() ?? globalPinia; + + const store = baseUseThemeStore(resolved); + ensureThemePersistence(store as ThemeStorePersistedShape, resolved); + + return store; +}) as typeof baseUseThemeStore; + +Object.assign(useThemeStore, baseUseThemeStore); + +export type { ThemeStorePersistedShape }; From 84f59874e7c336c7e0a98908c0cef4d8f29f951a Mon Sep 17 00:00:00 2001 From: Eli Bosley Date: Thu, 13 Nov 2025 15:25:32 -0500 Subject: [PATCH 2/5] fix: Refactor theme persistence logic to handle undefined values gracefully --- web/src/store/theme.ts | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/web/src/store/theme.ts b/web/src/store/theme.ts index 2898f5f32f..bc70ae6fa5 100644 --- a/web/src/store/theme.ts +++ b/web/src/store/theme.ts @@ -61,19 +61,19 @@ const ensureThemePersistence = (store: ThemeStorePersistedShape, pinia: Pinia) = const piniaWithApp = pinia as Pinia & { _a?: PiniaPluginContext['app'] }; persistThemeState({ - app: piniaWithApp._a, + app: piniaWithApp._a ?? undefined, pinia, store: store as unknown as PiniaPluginContext['store'], options: { persist: { key: THEME_STORAGE_KEY, - pick: ['theme'], - afterHydrate: ({ store: hydratedStore }) => { + paths: ['theme'], + afterHydrate: ({ store: hydratedStore }: { store: unknown }) => { const themeStore = hydratedStore as ThemeStorePersistedShape; themeStore.setTheme(themeStore.theme); }, }, - }, + } as unknown as PiniaPluginContext['options'], }); store.$hydrate?.({ runHooks: false }); @@ -130,10 +130,10 @@ const baseUseThemeStore = defineStore('theme', () => { name: publicTheme.name?.toLowerCase(), banner: publicTheme.showBannerImage, bannerGradient: publicTheme.showBannerGradient, - bgColor: publicTheme.headerBackgroundColor, + bgColor: publicTheme.headerBackgroundColor ?? undefined, descriptionShow: publicTheme.showHeaderDescription, - metaColor: publicTheme.headerSecondaryTextColor, - textColor: publicTheme.headerPrimaryTextColor, + metaColor: publicTheme.headerSecondaryTextColor ?? undefined, + textColor: publicTheme.headerPrimaryTextColor ?? undefined, }); }; From 9cf1c7e3c28d5c6dceb0948a1e5191d4c9fb0725 Mon Sep 17 00:00:00 2001 From: Eli Bosley Date: Thu, 13 Nov 2025 15:33:19 -0500 Subject: [PATCH 3/5] feat: integrate persisted state plugin for global Pinia store and simplify theme persistence --- web/src/store/globalPinia.ts | 3 + web/src/store/theme.ts | 421 ++++++++++++++++------------------- 2 files changed, 199 insertions(+), 225 deletions(-) diff --git a/web/src/store/globalPinia.ts b/web/src/store/globalPinia.ts index 38435c82b9..de77bd17d1 100644 --- a/web/src/store/globalPinia.ts +++ b/web/src/store/globalPinia.ts @@ -1,7 +1,10 @@ import { createPinia, setActivePinia } from 'pinia'; +import piniaPluginPersistedstate from 'pinia-plugin-persistedstate'; + // Create a single shared Pinia instance for all web components export const globalPinia = createPinia(); +globalPinia.use(piniaPluginPersistedstate); // IMPORTANT: Set it as the active pinia instance immediately // This ensures stores work even when called during component setup diff --git a/web/src/store/theme.ts b/web/src/store/theme.ts index bc70ae6fa5..0016b1467a 100644 --- a/web/src/store/theme.ts +++ b/web/src/store/theme.ts @@ -4,11 +4,10 @@ import { useQuery } from '@vue/apollo-composable'; import { defaultColors } from '~/themes/default'; import hexToRgba from 'hex-to-rgba'; -import { createPersistedState } from 'pinia-plugin-persistedstate'; import type { GetThemeQuery } from '~/composables/gql/graphql'; import type { Theme, ThemeVariables } from '~/themes/types'; -import type { Pinia, PiniaPluginContext } from 'pinia'; +import type { Pinia } from 'pinia'; import { graphql } from '~/composables/gql/gql'; import { globalPinia } from '~/store/globalPinia'; @@ -44,41 +43,6 @@ const DEFAULT_THEME: Theme = { type ThemeSource = 'local' | 'server'; -type ThemeStorePersistedShape = { - setTheme: (data?: Partial, options?: { source?: ThemeSource }) => void; - theme: Theme; - $hydrate?: PiniaPluginContext['store']['$hydrate']; - $persist?: PiniaPluginContext['store']['$persist']; -}; - -const persistThemeState = createPersistedState(); - -const ensureThemePersistence = (store: ThemeStorePersistedShape, pinia: Pinia) => { - if (typeof store.$persist === 'function') { - return; - } - - const piniaWithApp = pinia as Pinia & { _a?: PiniaPluginContext['app'] }; - - persistThemeState({ - app: piniaWithApp._a ?? undefined, - pinia, - store: store as unknown as PiniaPluginContext['store'], - options: { - persist: { - key: THEME_STORAGE_KEY, - paths: ['theme'], - afterHydrate: ({ store: hydratedStore }: { store: unknown }) => { - const themeStore = hydratedStore as ThemeStorePersistedShape; - themeStore.setTheme(themeStore.theme); - }, - }, - } as unknown as PiniaPluginContext['options'], - }); - - store.$hydrate?.({ runHooks: false }); -}; - const sanitizeTheme = (data: Partial | null | undefined): Theme | null => { if (!data || typeof data !== 'object') { return null; @@ -108,234 +72,241 @@ const DYNAMIC_VAR_KEYS = [ type DynamicVarKey = (typeof DYNAMIC_VAR_KEYS)[number]; -const baseUseThemeStore = defineStore('theme', () => { - // State - const theme = ref({ ...DEFAULT_THEME }); +const baseUseThemeStore = defineStore( + 'theme', + () => { + // State + const theme = ref({ ...DEFAULT_THEME }); - const activeColorVariables = ref(defaultColors.white); - const hasServerTheme = ref(false); - const devOverride = ref(false); + const activeColorVariables = ref(defaultColors.white); + const hasServerTheme = ref(false); + const devOverride = ref(false); - const { result, onResult, onError } = useQuery(GET_THEME_QUERY, null, { - fetchPolicy: 'cache-and-network', - nextFetchPolicy: 'cache-first', - }); - - const mapPublicTheme = (publicTheme?: GetThemeQuery['publicTheme'] | null): Theme | null => { - if (!publicTheme) { - return null; - } - - return sanitizeTheme({ - name: publicTheme.name?.toLowerCase(), - banner: publicTheme.showBannerImage, - bannerGradient: publicTheme.showBannerGradient, - bgColor: publicTheme.headerBackgroundColor ?? undefined, - descriptionShow: publicTheme.showHeaderDescription, - metaColor: publicTheme.headerSecondaryTextColor ?? undefined, - textColor: publicTheme.headerPrimaryTextColor ?? undefined, + const { result, onResult, onError } = useQuery(GET_THEME_QUERY, null, { + fetchPolicy: 'cache-and-network', + nextFetchPolicy: 'cache-first', }); - }; - const applyThemeFromQuery = (publicTheme?: GetThemeQuery['publicTheme'] | null) => { - const sanitized = mapPublicTheme(publicTheme); - if (!sanitized) { - return; - } - - setTheme(sanitized, { source: 'server' }); - }; - - onResult(({ data }) => { - if (data?.publicTheme) { - applyThemeFromQuery(data.publicTheme); - } - }); - - if (result.value?.publicTheme) { - applyThemeFromQuery(result.value.publicTheme); - } - - onError((err) => { - console.warn('Failed to load theme from server, keeping existing theme:', err); - }); + const mapPublicTheme = (publicTheme?: GetThemeQuery['publicTheme'] | null): Theme | null => { + if (!publicTheme) { + return null; + } - // Getters - // Apply dark mode for gray and black themes - const darkMode = computed(() => - DARK_UI_THEMES.includes(theme.value?.name as (typeof DARK_UI_THEMES)[number]) - ); + return sanitizeTheme({ + name: publicTheme.name?.toLowerCase(), + banner: publicTheme.showBannerImage, + bannerGradient: publicTheme.showBannerGradient, + bgColor: publicTheme.headerBackgroundColor ?? undefined, + descriptionShow: publicTheme.showHeaderDescription, + metaColor: publicTheme.headerSecondaryTextColor ?? undefined, + textColor: publicTheme.headerPrimaryTextColor ?? undefined, + }); + }; - const bannerGradient = computed(() => { - if (!theme.value?.banner || !theme.value?.bannerGradient) { - return undefined; - } - const start = theme.value?.bgColor ? 'var(--header-gradient-start)' : 'rgba(0, 0, 0, 0)'; - const end = theme.value?.bgColor ? 'var(--header-gradient-end)' : 'var(--header-background-color)'; - return `background-image: linear-gradient(90deg, ${start} 0, ${end} 90%);`; - }); - - // Actions - const setTheme = (data?: Partial, options: { source?: ThemeSource } = {}) => { - if (data) { - const { source = 'local' } = options; - - if (source === 'server') { - hasServerTheme.value = true; - } else if (hasServerTheme.value && !devOverride.value) { + const applyThemeFromQuery = (publicTheme?: GetThemeQuery['publicTheme'] | null) => { + const sanitized = mapPublicTheme(publicTheme); + if (!sanitized) { return; } - const sanitized = sanitizeTheme({ - ...theme.value, - ...data, - }); + setTheme(sanitized, { source: 'server' }); + }; - if (sanitized) { - theme.value = sanitized; + onResult(({ data }) => { + if (data?.publicTheme) { + applyThemeFromQuery(data.publicTheme); } + }); + + if (result.value?.publicTheme) { + applyThemeFromQuery(result.value.publicTheme); } - }; - const setDevOverride = (enabled: boolean) => { - devOverride.value = enabled; - }; + onError((err) => { + console.warn('Failed to load theme from server, keeping existing theme:', err); + }); - const setCssVars = () => { - const selectedTheme = theme.value.name; + // Getters + // Apply dark mode for gray and black themes + const darkMode = computed(() => + DARK_UI_THEMES.includes(theme.value?.name as (typeof DARK_UI_THEMES)[number]) + ); - // Prepare Tailwind v4 theme classes - const themeClasses: string[] = []; - const customClasses: string[] = []; + const bannerGradient = computed(() => { + if (!theme.value?.banner || !theme.value?.bannerGradient) { + return undefined; + } + const start = theme.value?.bgColor ? 'var(--header-gradient-start)' : 'rgba(0, 0, 0, 0)'; + const end = theme.value?.bgColor ? 'var(--header-gradient-end)' : 'var(--header-background-color)'; + return `background-image: linear-gradient(90deg, ${start} 0, ${end} 90%);`; + }); - // Apply dark/light mode using Tailwind v4 theme switching - if (darkMode.value) { - themeClasses.push('dark'); - } + // Actions + const setTheme = (data?: Partial, options: { source?: ThemeSource } = {}) => { + if (data) { + const { source = 'local' } = options; - // Apply theme-specific class for Tailwind v4 theme variants - themeClasses.push(`theme-${selectedTheme}`); + if (source === 'server') { + hasServerTheme.value = true; + } else if (hasServerTheme.value && !devOverride.value) { + return; + } - // Only set CSS variables for dynamic/user-configured values from GraphQL - // Static theme values are handled by Tailwind v4 theme classes in @tailwind-shared - const dynamicVars: Partial> = {}; + const sanitized = sanitizeTheme({ + ...theme.value, + ...data, + }); - // User-configured colors from webGUI @ /Settings/DisplaySettings - if (theme.value.textColor) { - dynamicVars['--custom-header-text-primary'] = theme.value.textColor; - customClasses.push('has-custom-header-text'); - } - if (theme.value.metaColor) { - dynamicVars['--custom-header-text-secondary'] = theme.value.metaColor; - customClasses.push('has-custom-header-meta'); - } + if (sanitized) { + theme.value = sanitized; + } + } + }; - if (theme.value.bgColor) { - dynamicVars['--custom-header-background-color'] = theme.value.bgColor; - dynamicVars['--custom-header-gradient-start'] = hexToRgba(theme.value.bgColor, 0); - dynamicVars['--custom-header-gradient-end'] = hexToRgba(theme.value.bgColor, 0.7); - customClasses.push('has-custom-header-bg'); - } + const setDevOverride = (enabled: boolean) => { + devOverride.value = enabled; + }; - // Set banner gradient if needed - if (theme.value.banner && theme.value.bannerGradient) { - const start = theme.value.bgColor - ? hexToRgba(theme.value.bgColor, 0) - : 'var(--header-gradient-start)'; - const end = theme.value.bgColor - ? hexToRgba(theme.value.bgColor, 0.7) - : 'var(--header-gradient-end)'; + const setCssVars = () => { + const selectedTheme = theme.value.name; - dynamicVars['--banner-gradient'] = `linear-gradient(90deg, ${start} 0, ${end} 90%)`; - } + // Prepare Tailwind v4 theme classes + const themeClasses: string[] = []; + const customClasses: string[] = []; - requestAnimationFrame(() => { - const scopedTargets: HTMLElement[] = [ - document.documentElement, - ...Array.from(document.querySelectorAll('.unapi')), - ]; + // Apply dark/light mode using Tailwind v4 theme switching + if (darkMode.value) { + themeClasses.push('dark'); + } - const cleanClassList = (classList: string) => - classList - .split(' ') - .filter((c) => !c.startsWith('theme-') && c !== 'dark' && !c.startsWith('has-custom-')) - .filter(Boolean) - .join(' '); + // Apply theme-specific class for Tailwind v4 theme variants + themeClasses.push(`theme-${selectedTheme}`); - // Apply theme and custom classes to html element and all .unapi roots - scopedTargets.forEach((target) => { - target.className = cleanClassList(target.className); - [...themeClasses, ...customClasses].forEach((cls) => target.classList.add(cls)); + // Only set CSS variables for dynamic/user-configured values from GraphQL + // Static theme values are handled by Tailwind v4 theme classes in @tailwind-shared + const dynamicVars: Partial> = {}; - if (darkMode.value) { - target.classList.add('dark'); - } else { - target.classList.remove('dark'); - } - }); + // User-configured colors from webGUI @ /Settings/DisplaySettings + if (theme.value.textColor) { + dynamicVars['--custom-header-text-primary'] = theme.value.textColor; + customClasses.push('has-custom-header-text'); + } + if (theme.value.metaColor) { + dynamicVars['--custom-header-text-secondary'] = theme.value.metaColor; + customClasses.push('has-custom-header-meta'); + } - // Maintain dark mode flag on body for legacy components - if (darkMode.value) { - document.body.classList.add('dark'); - } else { - document.body.classList.remove('dark'); + if (theme.value.bgColor) { + dynamicVars['--custom-header-background-color'] = theme.value.bgColor; + dynamicVars['--custom-header-gradient-start'] = hexToRgba(theme.value.bgColor, 0); + dynamicVars['--custom-header-gradient-end'] = hexToRgba(theme.value.bgColor, 0.7); + customClasses.push('has-custom-header-bg'); } - // Only apply dynamic CSS variables for custom user values - // All theme defaults are handled by classes in @tailwind-shared/theme-variants.css - const activeDynamicKeys = Object.keys(dynamicVars) as DynamicVarKey[]; + // Set banner gradient if needed + if (theme.value.banner && theme.value.bannerGradient) { + const start = theme.value.bgColor + ? hexToRgba(theme.value.bgColor, 0) + : 'var(--header-gradient-start)'; + const end = theme.value.bgColor + ? hexToRgba(theme.value.bgColor, 0.7) + : 'var(--header-gradient-end)'; + + dynamicVars['--banner-gradient'] = `linear-gradient(90deg, ${start} 0, ${end} 90%)`; + } - scopedTargets.forEach((target) => { - activeDynamicKeys.forEach((key) => { - const value = dynamicVars[key]; - if (value !== undefined) { - target.style.setProperty(key, value); + requestAnimationFrame(() => { + const scopedTargets: HTMLElement[] = [ + document.documentElement, + ...Array.from(document.querySelectorAll('.unapi')), + ]; + + const cleanClassList = (classList: string) => + classList + .split(' ') + .filter((c) => !c.startsWith('theme-') && c !== 'dark' && !c.startsWith('has-custom-')) + .filter(Boolean) + .join(' '); + + // Apply theme and custom classes to html element and all .unapi roots + scopedTargets.forEach((target) => { + target.className = cleanClassList(target.className); + [...themeClasses, ...customClasses].forEach((cls) => target.classList.add(cls)); + + if (darkMode.value) { + target.classList.add('dark'); + } else { + target.classList.remove('dark'); } }); - DYNAMIC_VAR_KEYS.forEach((key) => { - if (!Object.prototype.hasOwnProperty.call(dynamicVars, key)) { - target.style.removeProperty(key); - } + // Maintain dark mode flag on body for legacy components + if (darkMode.value) { + document.body.classList.add('dark'); + } else { + document.body.classList.remove('dark'); + } + + // Only apply dynamic CSS variables for custom user values + // All theme defaults are handled by classes in @tailwind-shared/theme-variants.css + const activeDynamicKeys = Object.keys(dynamicVars) as DynamicVarKey[]; + + scopedTargets.forEach((target) => { + activeDynamicKeys.forEach((key) => { + const value = dynamicVars[key]; + if (value !== undefined) { + target.style.setProperty(key, value); + } + }); + + DYNAMIC_VAR_KEYS.forEach((key) => { + if (!Object.prototype.hasOwnProperty.call(dynamicVars, key)) { + target.style.removeProperty(key); + } + }); }); - }); - // Store active variables for reference (from defaultColors for compatibility) - const customTheme = { ...defaultColors[selectedTheme] }; - activeColorVariables.value = customTheme; - }); - }; + // Store active variables for reference (from defaultColors for compatibility) + const customTheme = { ...defaultColors[selectedTheme] }; + activeColorVariables.value = customTheme; + }); + }; - watch( - theme, - () => { - setCssVars(); + watch( + theme, + () => { + setCssVars(); + }, + { immediate: true } + ); + + return { + // state + activeColorVariables, + bannerGradient, + darkMode, + theme, + // actions + setTheme, + setCssVars, + setDevOverride, + }; + }, + { + persist: { + key: THEME_STORAGE_KEY, + pick: ['theme'], + afterHydrate: (ctx) => { + const store = ctx.store as ReturnType; + store.setTheme(store.theme); + }, }, - { immediate: true } - ); - - return { - // state - activeColorVariables, - bannerGradient, - darkMode, - theme, - // actions - setTheme, - setCssVars, - setDevOverride, - }; -}); + } +); export const useThemeStore = ((pinia?: Pinia) => { const resolved = pinia ?? getActivePinia() ?? globalPinia; - - const store = baseUseThemeStore(resolved); - ensureThemePersistence(store as ThemeStorePersistedShape, resolved); - - return store; + return baseUseThemeStore(resolved); }) as typeof baseUseThemeStore; Object.assign(useThemeStore, baseUseThemeStore); - -export type { ThemeStorePersistedShape }; From 92daef8aa327083bd726de65bc14dee31f9dd72c Mon Sep 17 00:00:00 2001 From: Eli Bosley Date: Thu, 13 Nov 2025 15:34:58 -0500 Subject: [PATCH 4/5] test: enhance theme store tests by integrating Vue app instance --- web/__test__/store/theme.test.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/web/__test__/store/theme.test.ts b/web/__test__/store/theme.test.ts index a3ebcb9a23..b574e21fdc 100644 --- a/web/__test__/store/theme.test.ts +++ b/web/__test__/store/theme.test.ts @@ -2,7 +2,7 @@ * Theme store test coverage */ -import { nextTick, ref } from 'vue'; +import { createApp, nextTick, ref } from 'vue'; import { setActivePinia } from 'pinia'; import { defaultColors } from '~/themes/default'; @@ -36,8 +36,11 @@ describe('Theme Store', () => { const originalDocumentElementRemoveClass = document.documentElement.classList.remove; let store: ReturnType | undefined; + let app: ReturnType | undefined; beforeEach(() => { + app = createApp({ render: () => null }); + app.use(globalPinia); setActivePinia(globalPinia); store = undefined; window.localStorage.clear(); @@ -61,6 +64,8 @@ describe('Theme Store', () => { afterEach(() => { store?.$dispose(); store = undefined; + app?.unmount(); + app = undefined; document.body.classList.add = originalAddClassFn; document.body.classList.remove = originalRemoveClassFn; From f04aacb440037a5fb72bc642c30549c489f42e01 Mon Sep 17 00:00:00 2001 From: Eli Bosley Date: Thu, 13 Nov 2025 16:08:42 -0500 Subject: [PATCH 5/5] refactor: streamline theme store definition and remove unused code - Updated the theme store to export the useThemeStore directly, simplifying its usage. - Removed the baseUseThemeStore function and associated global Pinia references, enhancing clarity and maintainability. --- web/src/store/theme.ts | 15 +++------------ 1 file changed, 3 insertions(+), 12 deletions(-) diff --git a/web/src/store/theme.ts b/web/src/store/theme.ts index 0016b1467a..92c200cde3 100644 --- a/web/src/store/theme.ts +++ b/web/src/store/theme.ts @@ -1,5 +1,5 @@ import { computed, ref, watch } from 'vue'; -import { defineStore, getActivePinia } from 'pinia'; +import { defineStore } from 'pinia'; import { useQuery } from '@vue/apollo-composable'; import { defaultColors } from '~/themes/default'; @@ -7,10 +7,8 @@ import hexToRgba from 'hex-to-rgba'; import type { GetThemeQuery } from '~/composables/gql/graphql'; import type { Theme, ThemeVariables } from '~/themes/types'; -import type { Pinia } from 'pinia'; import { graphql } from '~/composables/gql/gql'; -import { globalPinia } from '~/store/globalPinia'; // Themes that should apply the .dark class (dark UI themes) export const DARK_UI_THEMES = ['gray', 'black'] as const; @@ -72,7 +70,7 @@ const DYNAMIC_VAR_KEYS = [ type DynamicVarKey = (typeof DYNAMIC_VAR_KEYS)[number]; -const baseUseThemeStore = defineStore( +export const useThemeStore = defineStore( 'theme', () => { // State @@ -297,16 +295,9 @@ const baseUseThemeStore = defineStore( key: THEME_STORAGE_KEY, pick: ['theme'], afterHydrate: (ctx) => { - const store = ctx.store as ReturnType; + const store = ctx.store as ReturnType; store.setTheme(store.theme); }, }, } ); - -export const useThemeStore = ((pinia?: Pinia) => { - const resolved = pinia ?? getActivePinia() ?? globalPinia; - return baseUseThemeStore(resolved); -}) as typeof baseUseThemeStore; - -Object.assign(useThemeStore, baseUseThemeStore);