From e0ff6e356f574b52904e0bfc00f11b16cd86513a Mon Sep 17 00:00:00 2001 From: Eli Bosley Date: Mon, 24 Nov 2025 16:06:10 -0500 Subject: [PATCH 01/21] fix: enhance dark mode support in theme handling - Added PHP logic to determine if the current theme is dark and set a CSS variable accordingly. - Introduced a new function to retrieve the dark mode state from the CSS variable in JavaScript. - Updated the theme store to initialize dark mode based on the CSS variable, ensuring consistent theme application across the application. This improves user experience by ensuring the correct theme is applied based on user preferences. --- .../include/web-components-extractor.php | 4 +++ web/src/store/theme.ts | 28 +++++++++++++------ 2 files changed, 23 insertions(+), 9 deletions(-) diff --git a/plugin/source/dynamix.unraid.net/usr/local/emhttp/plugins/dynamix.my.servers/include/web-components-extractor.php b/plugin/source/dynamix.unraid.net/usr/local/emhttp/plugins/dynamix.my.servers/include/web-components-extractor.php index fb001d6fb9..e87cc670e9 100644 --- a/plugin/source/dynamix.unraid.net/usr/local/emhttp/plugins/dynamix.my.servers/include/web-components-extractor.php +++ b/plugin/source/dynamix.unraid.net/usr/local/emhttp/plugins/dynamix.my.servers/include/web-components-extractor.php @@ -209,6 +209,10 @@ private function getDisplayThemeVars(): ?array } $theme = strtolower(trim($display['theme'] ?? '')); + $darkThemes = ['gray', 'black']; + $isDarkMode = in_array($theme, $darkThemes, true); + $vars['--theme-dark-mode'] = $isDarkMode ? '1' : '0'; + if ($theme === 'white') { if (!$textPrimary) { $vars['--header-text-primary'] = 'var(--inverse-text-color, #ffffff)'; diff --git a/web/src/store/theme.ts b/web/src/store/theme.ts index 15ec1e8bd7..ce39a9e99a 100644 --- a/web/src/store/theme.ts +++ b/web/src/store/theme.ts @@ -50,16 +50,21 @@ const syncBodyDarkClass = (method: 'add' | 'remove'): boolean => { return true; }; +const getDarkModeFromCssVar = (): boolean => { + if (typeof document === 'undefined') return false; + + const darkModeValue = getComputedStyle(document.documentElement) + .getPropertyValue('--theme-dark-mode') + .trim(); + return darkModeValue === '1'; +}; + const applyDarkClass = (isDark: boolean) => { if (typeof document === 'undefined') return; const method: 'add' | 'remove' = isDark ? 'add' : 'remove'; document.documentElement.classList[method]('dark'); - - const unapiElements = document.querySelectorAll('.unapi'); - unapiElements.forEach((element) => { - element.classList[method]('dark'); - }); + document.documentElement.style.setProperty('--theme-dark-mode', isDark ? '1' : '0'); if (pendingDarkModeHandler) { document.removeEventListener('DOMContentLoaded', pendingDarkModeHandler); @@ -72,10 +77,6 @@ const applyDarkClass = (isDark: boolean) => { const handler = () => { if (syncBodyDarkClass(method)) { - const unapiElementsOnLoad = document.querySelectorAll('.unapi'); - unapiElementsOnLoad.forEach((element) => { - element.classList[method]('dark'); - }); document.removeEventListener('DOMContentLoaded', handler); if (pendingDarkModeHandler === handler) { pendingDarkModeHandler = null; @@ -113,6 +114,15 @@ export const useThemeStore = defineStore('theme', () => { const hasServerTheme = ref(false); const devOverride = ref(false); + // Initialize dark mode from CSS variable set by PHP + if (typeof document !== 'undefined') { + const initialDarkMode = getDarkModeFromCssVar(); + if (initialDarkMode) { + document.documentElement.classList.add('dark'); + syncBodyDarkClass('add'); + } + } + const { result, onResult, onError } = useQuery(GET_THEME_QUERY, null, { fetchPolicy: 'cache-and-network', nextFetchPolicy: 'cache-first', From 1b59aa0d31ca0d94dcc8ea6d56dd7fbb09c99b4e Mon Sep 17 00:00:00 2001 From: Eli Bosley Date: Mon, 24 Nov 2025 16:29:21 -0500 Subject: [PATCH 02/21] test: update theme store tests to initialize dark mode from CSS variable - Removed the previous test for applying dark mode classes to .unapi elements. - Added a new test to verify that dark mode is initialized based on a CSS variable when the store is created. - Mocked getComputedStyle to simulate dark mode and checked that the appropriate classes are added to document elements. This enhances the test coverage for theme handling in the application. --- web/__test__/store/theme.test.ts | 56 +++++++++++++------------------- 1 file changed, 22 insertions(+), 34 deletions(-) diff --git a/web/__test__/store/theme.test.ts b/web/__test__/store/theme.test.ts index 0a574016c7..8e3dab2f45 100644 --- a/web/__test__/store/theme.test.ts +++ b/web/__test__/store/theme.test.ts @@ -197,44 +197,32 @@ describe('Theme Store', () => { expect(document.body.classList.add).toHaveBeenCalledWith('dark'); }); - it('should apply dark mode classes to all .unapi elements', async () => { - const store = createStore(); - - const unapiElement1 = document.createElement('div'); - unapiElement1.classList.add('unapi'); - document.body.appendChild(unapiElement1); - - const unapiElement2 = document.createElement('div'); - unapiElement2.classList.add('unapi'); - document.body.appendChild(unapiElement2); - - const addSpy1 = vi.spyOn(unapiElement1.classList, 'add'); - const addSpy2 = vi.spyOn(unapiElement2.classList, 'add'); - const removeSpy1 = vi.spyOn(unapiElement1.classList, 'remove'); - const removeSpy2 = vi.spyOn(unapiElement2.classList, 'remove'); - - store.setTheme({ - ...store.theme, - name: 'black', - }); - - await nextTick(); - - expect(addSpy1).toHaveBeenCalledWith('dark'); - expect(addSpy2).toHaveBeenCalledWith('dark'); - - store.setTheme({ - ...store.theme, - name: 'white', + it('should initialize dark mode from CSS variable on store creation', () => { + // Mock getComputedStyle to return dark mode + const originalGetComputedStyle = window.getComputedStyle; + vi.spyOn(window, 'getComputedStyle').mockImplementation((el) => { + const style = originalGetComputedStyle(el); + if (el === document.documentElement) { + return { + ...style, + getPropertyValue: (prop: string) => { + if (prop === '--theme-dark-mode') { + return '1'; + } + return style.getPropertyValue(prop); + }, + } as CSSStyleDeclaration; + } + return style; }); - await nextTick(); + const store = createStore(); - expect(removeSpy1).toHaveBeenCalledWith('dark'); - expect(removeSpy2).toHaveBeenCalledWith('dark'); + // Should have added dark class to documentElement + expect(document.documentElement.classList.add).toHaveBeenCalledWith('dark'); + expect(document.body.classList.add).toHaveBeenCalledWith('dark'); - document.body.removeChild(unapiElement1); - document.body.removeChild(unapiElement2); + vi.restoreAllMocks(); }); }); }); From be3b9ae4ab1cd6c076fffc7378f134ff2c9d002b Mon Sep 17 00:00:00 2001 From: Eli Bosley Date: Mon, 24 Nov 2025 16:37:18 -0500 Subject: [PATCH 03/21] test: simplify theme store test initialization - Removed the unnecessary assignment of the store variable in the theme store test. - This change streamlines the test setup while maintaining the verification of dark mode class application. This update enhances the clarity and efficiency of the test code. --- web/__test__/store/theme.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web/__test__/store/theme.test.ts b/web/__test__/store/theme.test.ts index 8e3dab2f45..ddc7570321 100644 --- a/web/__test__/store/theme.test.ts +++ b/web/__test__/store/theme.test.ts @@ -216,7 +216,7 @@ describe('Theme Store', () => { return style; }); - const store = createStore(); + createStore(); // Should have added dark class to documentElement expect(document.documentElement.classList.add).toHaveBeenCalledWith('dark'); From d2d9264b0ce65c14fa667e8082255929c512c12b Mon Sep 17 00:00:00 2001 From: Eli Bosley Date: Tue, 25 Nov 2025 10:11:57 -0500 Subject: [PATCH 04/21] refactor: enhance dark mode handling and theme initialization - Updated the WebComponentsExtractor to set the theme name and conditionally apply gradient variables based on banner visibility. - Improved the useTeleport and mount-engine functions to inherit dark mode from the document, ensuring consistency across components. - Refactored the theme store to initialize dark mode based on CSS variables and streamline theme management, including lazy loading of theme queries. These changes improve the user experience by ensuring that the correct theme and dark mode settings are applied consistently throughout the application. --- .../include/web-components-extractor.php | 11 +- unraid-ui/src/composables/useTeleport.ts | 8 + web/__test__/store/theme.test.ts | 122 +++++++++++--- web/src/components/Wrapper/mount-engine.ts | 9 + web/src/store/theme.ts | 159 ++++++++++-------- 5 files changed, 212 insertions(+), 97 deletions(-) diff --git a/plugin/source/dynamix.unraid.net/usr/local/emhttp/plugins/dynamix.my.servers/include/web-components-extractor.php b/plugin/source/dynamix.unraid.net/usr/local/emhttp/plugins/dynamix.my.servers/include/web-components-extractor.php index e87cc670e9..6b2dfdf155 100644 --- a/plugin/source/dynamix.unraid.net/usr/local/emhttp/plugins/dynamix.my.servers/include/web-components-extractor.php +++ b/plugin/source/dynamix.unraid.net/usr/local/emhttp/plugins/dynamix.my.servers/include/web-components-extractor.php @@ -212,6 +212,7 @@ private function getDisplayThemeVars(): ?array $darkThemes = ['gray', 'black']; $isDarkMode = in_array($theme, $darkThemes, true); $vars['--theme-dark-mode'] = $isDarkMode ? '1' : '0'; + $vars['--theme-name'] = $theme ?: 'white'; if ($theme === 'white') { if (!$textPrimary) { @@ -222,15 +223,19 @@ private function getDisplayThemeVars(): ?array } } + $shouldShowBanner = ($display['showBannerImage'] ?? '') === 'yes'; $bgColor = $this->normalizeHex($display['background'] ?? null); if ($bgColor) { $vars['--header-background-color'] = $bgColor; - $vars['--header-gradient-start'] = $this->hexToRgba($bgColor, 0); - $vars['--header-gradient-end'] = $this->hexToRgba($bgColor, 0.7); + // Only set gradient variables if banner image is enabled + if ($shouldShowBanner) { + $vars['--header-gradient-start'] = $this->hexToRgba($bgColor, 0); + $vars['--header-gradient-end'] = $this->hexToRgba($bgColor, 0.7); + } } $shouldShowBannerGradient = ($display['showBannerGradient'] ?? '') === 'yes'; - if ($shouldShowBannerGradient) { + if ($shouldShowBanner && $shouldShowBannerGradient) { $start = $vars['--header-gradient-start'] ?? 'rgba(0, 0, 0, 0)'; $end = $vars['--header-gradient-end'] ?? 'rgba(0, 0, 0, 0.7)'; $vars['--banner-gradient'] = sprintf( diff --git a/unraid-ui/src/composables/useTeleport.ts b/unraid-ui/src/composables/useTeleport.ts index d0ec36663e..fd63c22ef0 100644 --- a/unraid-ui/src/composables/useTeleport.ts +++ b/unraid-ui/src/composables/useTeleport.ts @@ -9,6 +9,14 @@ const ensureVirtualContainer = () => { virtualModalContainer.className = 'unapi'; virtualModalContainer.style.position = 'relative'; virtualModalContainer.style.zIndex = '999999'; + // Inherit dark mode if it's already applied to the page so teleported sheets stay in sync + const isDark = + document.documentElement.classList.contains('dark') || + document.body?.classList.contains('dark') || + Boolean(document.querySelector('.unapi.dark')); + if (isDark) { + virtualModalContainer.classList.add('dark'); + } document.body.appendChild(virtualModalContainer); } return virtualModalContainer; diff --git a/web/__test__/store/theme.test.ts b/web/__test__/store/theme.test.ts index ddc7570321..359f9b831f 100644 --- a/web/__test__/store/theme.test.ts +++ b/web/__test__/store/theme.test.ts @@ -18,6 +18,13 @@ vi.mock('@vue/apollo-composable', () => ({ onResult: vi.fn(), onError: vi.fn(), }), + useLazyQuery: () => ({ + load: vi.fn(), + result: ref(null), + loading: ref(false), + onResult: vi.fn(), + onError: vi.fn(), + }), })); describe('Theme Store', () => { @@ -90,44 +97,102 @@ describe('Theme Store', () => { expect(store.activeColorVariables).toEqual(defaultColors.white); }); - it('should compute darkMode correctly', () => { - const store = createStore(); - - expect(store.darkMode).toBe(false); - - store.setTheme({ ...store.theme, name: 'black' }); - expect(store.darkMode).toBe(true); + it('should compute darkMode from CSS variable when set to 1', () => { + const originalGetComputedStyle = window.getComputedStyle; + vi.spyOn(window, 'getComputedStyle').mockImplementation((el) => { + const style = originalGetComputedStyle(el); + if (el === document.documentElement) { + return { + ...style, + getPropertyValue: (prop: string) => { + if (prop === '--theme-dark-mode') { + return '1'; + } + return style.getPropertyValue(prop); + }, + } as CSSStyleDeclaration; + } + return style; + }); - store.setTheme({ ...store.theme, name: 'gray' }); + const store = createStore(); expect(store.darkMode).toBe(true); - store.setTheme({ ...store.theme, name: 'white' }); - expect(store.darkMode).toBe(false); + vi.restoreAllMocks(); }); - it('should compute bannerGradient correctly', () => { + it('should compute darkMode from CSS variable when set to 0', () => { + const originalGetComputedStyle = window.getComputedStyle; + vi.spyOn(window, 'getComputedStyle').mockImplementation((el) => { + const style = originalGetComputedStyle(el); + if (el === document.documentElement) { + return { + ...style, + getPropertyValue: (prop: string) => { + if (prop === '--theme-dark-mode') { + return '0'; + } + return style.getPropertyValue(prop); + }, + } as CSSStyleDeclaration; + } + return style; + }); + const store = createStore(); + expect(store.darkMode).toBe(false); - expect(store.bannerGradient).toBeUndefined(); + vi.restoreAllMocks(); + }); - store.setTheme({ - ...store.theme, - banner: true, - bannerGradient: true, + it('should compute bannerGradient from CSS variable when set', () => { + const originalGetComputedStyle = window.getComputedStyle; + vi.spyOn(window, 'getComputedStyle').mockImplementation((el) => { + const style = originalGetComputedStyle(el); + if (el === document.documentElement) { + return { + ...style, + getPropertyValue: (prop: string) => { + if (prop === '--banner-gradient') { + return 'linear-gradient(90deg, rgba(0, 0, 0, 0) 0, rgba(0, 0, 0, 0.7) 90%)'; + } + return style.getPropertyValue(prop); + }, + } as CSSStyleDeclaration; + } + return style; }); - expect(store.bannerGradient).toMatchInlineSnapshot( - `"background-image: linear-gradient(90deg, rgba(0, 0, 0, 0) 0, var(--header-background-color) 90%);"` + + const store = createStore(); + expect(store.bannerGradient).toBe( + 'background-image: linear-gradient(90deg, rgba(0, 0, 0, 0) 0, rgba(0, 0, 0, 0.7) 90%);' ); - store.setTheme({ - ...store.theme, - banner: true, - bannerGradient: true, - bgColor: '#123456', + vi.restoreAllMocks(); + }); + + it('should return undefined when bannerGradient CSS variable is not set', () => { + const originalGetComputedStyle = window.getComputedStyle; + vi.spyOn(window, 'getComputedStyle').mockImplementation((el) => { + const style = originalGetComputedStyle(el); + if (el === document.documentElement) { + return { + ...style, + getPropertyValue: (prop: string) => { + if (prop === '--banner-gradient') { + return ''; + } + return style.getPropertyValue(prop); + }, + } as CSSStyleDeclaration; + } + return style; }); - expect(store.bannerGradient).toMatchInlineSnapshot( - `"background-image: linear-gradient(90deg, var(--header-gradient-start) 0, var(--header-gradient-end) 90%);"` - ); + + const store = createStore(); + expect(store.bannerGradient).toBeUndefined(); + + vi.restoreAllMocks(); }); }); @@ -209,6 +274,9 @@ describe('Theme Store', () => { if (prop === '--theme-dark-mode') { return '1'; } + if (prop === '--theme-name') { + return 'black'; + } return style.getPropertyValue(prop); }, } as CSSStyleDeclaration; @@ -218,7 +286,7 @@ describe('Theme Store', () => { createStore(); - // Should have added dark class to documentElement + // Should have added dark class to documentElement and body expect(document.documentElement.classList.add).toHaveBeenCalledWith('dark'); expect(document.body.classList.add).toHaveBeenCalledWith('dark'); diff --git a/web/src/components/Wrapper/mount-engine.ts b/web/src/components/Wrapper/mount-engine.ts index 0d9c25ecd6..e8d5e1ad81 100644 --- a/web/src/components/Wrapper/mount-engine.ts +++ b/web/src/components/Wrapper/mount-engine.ts @@ -179,6 +179,15 @@ export async function mountUnifiedApp() { element.setAttribute('data-vue-mounted', 'true'); element.classList.add('unapi'); + // Apply dark mode class if active + // Check both documentElement class and CSS variable (set by PHP) + const isDarkMode = + document.documentElement.classList.contains('dark') || + getComputedStyle(document.documentElement).getPropertyValue('--theme-dark-mode').trim() === '1'; + if (isDarkMode) { + element.classList.add('dark'); + } + // Store for cleanup mountedComponents.push({ element, diff --git a/web/src/store/theme.ts b/web/src/store/theme.ts index ce39a9e99a..bc32327b3e 100644 --- a/web/src/store/theme.ts +++ b/web/src/store/theme.ts @@ -1,6 +1,6 @@ import { computed, ref, watch } from 'vue'; import { defineStore } from 'pinia'; -import { useQuery } from '@vue/apollo-composable'; +import { useLazyQuery } from '@vue/apollo-composable'; import { defaultColors } from '~/themes/default'; @@ -38,54 +38,50 @@ const DEFAULT_THEME: Theme = { type ThemeSource = 'local' | 'server'; -let pendingDarkModeHandler: ((event: Event) => void) | null = null; +const isDomAvailable = () => typeof document !== 'undefined'; -const syncBodyDarkClass = (method: 'add' | 'remove'): boolean => { - const body = typeof document !== 'undefined' ? document.body : null; - if (!body) { - return false; - } +const getCssVar = (name: string): string => { + if (!isDomAvailable()) return ''; + return getComputedStyle(document.documentElement).getPropertyValue(name).trim(); +}; - body.classList[method]('dark'); - return true; +const readDomThemeName = () => getCssVar('--theme-name'); + +const readDomDarkFlag = (): boolean | null => { + if (!isDomAvailable()) return null; + const cssVar = getCssVar('--theme-dark-mode'); + if (cssVar === '1') return true; + if (cssVar === '0') return false; + return null; }; -const getDarkModeFromCssVar = (): boolean => { - if (typeof document === 'undefined') return false; +const hasDarkClassApplied = (): boolean => { + if (!isDomAvailable()) return false; + if (document.documentElement.classList.contains('dark')) return true; + if (document.body?.classList.contains('dark')) return true; + return Boolean(document.querySelector('.unapi.dark')); +}; - const darkModeValue = getComputedStyle(document.documentElement) - .getPropertyValue('--theme-dark-mode') - .trim(); - return darkModeValue === '1'; +const syncDarkClass = (method: 'add' | 'remove') => { + if (!isDomAvailable()) return; + document.documentElement.classList[method]('dark'); + document.body?.classList[method]('dark'); + document.querySelectorAll('.unapi').forEach((el) => el.classList[method]('dark')); }; const applyDarkClass = (isDark: boolean) => { - if (typeof document === 'undefined') return; - + if (!isDomAvailable()) return; const method: 'add' | 'remove' = isDark ? 'add' : 'remove'; - document.documentElement.classList[method]('dark'); + syncDarkClass(method); document.documentElement.style.setProperty('--theme-dark-mode', isDark ? '1' : '0'); +}; - if (pendingDarkModeHandler) { - document.removeEventListener('DOMContentLoaded', pendingDarkModeHandler); - pendingDarkModeHandler = null; - } - - if (syncBodyDarkClass(method)) { - return; +const bootstrapDarkClass = () => { + const domFlag = readDomDarkFlag(); + const shouldBeDark = domFlag ?? hasDarkClassApplied(); + if (shouldBeDark) { + applyDarkClass(true); } - - const handler = () => { - if (syncBodyDarkClass(method)) { - document.removeEventListener('DOMContentLoaded', handler); - if (pendingDarkModeHandler === handler) { - pendingDarkModeHandler = null; - } - } - }; - - pendingDarkModeHandler = handler; - document.addEventListener('DOMContentLoaded', handler); }; const sanitizeTheme = (data: Partial | null | undefined): Theme | null => { @@ -114,16 +110,11 @@ export const useThemeStore = defineStore('theme', () => { const hasServerTheme = ref(false); const devOverride = ref(false); - // Initialize dark mode from CSS variable set by PHP - if (typeof document !== 'undefined') { - const initialDarkMode = getDarkModeFromCssVar(); - if (initialDarkMode) { - document.documentElement.classList.add('dark'); - syncBodyDarkClass('add'); - } - } + // Initialize dark mode from CSS variable set by PHP or any pre-applied .dark class + bootstrapDarkClass(); - const { result, onResult, onError } = useQuery(GET_THEME_QUERY, null, { + // Lazy query - only executes when explicitly called + const { load, onResult, onError } = useLazyQuery(GET_THEME_QUERY, null, { fetchPolicy: 'cache-and-network', nextFetchPolicy: 'cache-first', }); @@ -159,27 +150,49 @@ export const useThemeStore = defineStore('theme', () => { } }); - if (result.value?.publicTheme) { - applyThemeFromQuery(result.value.publicTheme); - } - onError((err) => { console.warn('Failed to load theme from server, keeping existing theme:', err); }); - // 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]) - ); + // Getters - read from DOM CSS variables set by PHP + const themeName = computed(() => { + if (!isDomAvailable()) return DEFAULT_THEME.name; + const name = readDomThemeName() || theme.value.name; + return name || DEFAULT_THEME.name; + }); + + const darkMode = computed(() => { + if (!isDomAvailable()) return false; + const flag = readDomDarkFlag(); + return flag ?? hasDarkClassApplied(); + }); + + const readBannerGradientVar = (): string => { + const raw = getCssVar('--banner-gradient'); + if (!raw) return ''; + const normalized = raw.trim().toLowerCase(); + if (!normalized || normalized === 'null' || normalized === 'none' || normalized === 'undefined') { + return ''; + } + return raw; + }; const bannerGradient = computed(() => { - if (!theme.value?.banner || !theme.value?.bannerGradient) { + if (darkMode.value) { + return undefined; + } + const { banner, bannerGradient } = theme.value; + if (!banner || !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%);`; + const gradient = readBannerGradientVar(); + // Only return gradient if CSS variable is set and not empty + // CSS variable is only set by PHP when both banner and gradient are enabled + if (gradient) { + return `background-image: ${gradient};`; + } + // No fallback - only use CSS variable set by PHP + return undefined; }); // Actions @@ -212,27 +225,39 @@ export const useThemeStore = defineStore('theme', () => { devOverride.value = enabled; }; - const setCssVars = () => { - applyDarkClass(darkMode.value); + const fetchTheme = () => { + load(); }; + // Only apply dark class when theme changes (for dev tools that don't refresh) + // In production, PHP sets the dark class and page refreshes on theme change watch( - theme, - () => { - setCssVars(); + () => theme.value.name, + (themeName) => { + const isDark = DARK_UI_THEMES.includes(themeName as (typeof DARK_UI_THEMES)[number]); + applyDarkClass(isDark); }, - { immediate: true } + { immediate: false } ); + // Initialize theme from DOM on store creation + const domThemeName = themeName.value; + if (domThemeName && domThemeName !== DEFAULT_THEME.name) { + theme.value.name = domThemeName; + } + return { // state activeColorVariables, bannerGradient, darkMode, - theme, + theme: computed(() => ({ + ...theme.value, + name: themeName.value, + })), // actions setTheme, - setCssVars, setDevOverride, + fetchTheme, }; }); From 73a32e263491388812b14ab0d89412e2e993ac01 Mon Sep 17 00:00:00 2001 From: Eli Bosley Date: Tue, 25 Nov 2025 10:53:48 -0500 Subject: [PATCH 05/21] feat: enhance banner gradient customization and update related tests - Updated the WebComponentsExtractor to utilize a CSS variable for the banner gradient stop, allowing for responsive adjustments. - Modified theme store tests to reflect the new gradient format, ensuring consistency in gradient rendering. - Added CSS rules to define the banner gradient stop, with a media query for different screen sizes. These changes improve the flexibility and responsiveness of the banner gradient in the application. --- .../include/web-components-extractor.php | 2 +- web/__test__/store/theme.test.ts | 4 ++-- web/src/assets/main.css | 11 +++++++++++ web/src/components/UserProfile.standalone.vue | 2 +- 4 files changed, 15 insertions(+), 4 deletions(-) diff --git a/plugin/source/dynamix.unraid.net/usr/local/emhttp/plugins/dynamix.my.servers/include/web-components-extractor.php b/plugin/source/dynamix.unraid.net/usr/local/emhttp/plugins/dynamix.my.servers/include/web-components-extractor.php index 6b2dfdf155..7858f09a8f 100644 --- a/plugin/source/dynamix.unraid.net/usr/local/emhttp/plugins/dynamix.my.servers/include/web-components-extractor.php +++ b/plugin/source/dynamix.unraid.net/usr/local/emhttp/plugins/dynamix.my.servers/include/web-components-extractor.php @@ -239,7 +239,7 @@ private function getDisplayThemeVars(): ?array $start = $vars['--header-gradient-start'] ?? 'rgba(0, 0, 0, 0)'; $end = $vars['--header-gradient-end'] ?? 'rgba(0, 0, 0, 0.7)'; $vars['--banner-gradient'] = sprintf( - 'linear-gradient(90deg, %s 0, %s 90%%)', + 'linear-gradient(90deg, %s 0, %s var(--banner-gradient-stop, 30%%))', $start, $end ); diff --git a/web/__test__/store/theme.test.ts b/web/__test__/store/theme.test.ts index 359f9b831f..731950963d 100644 --- a/web/__test__/store/theme.test.ts +++ b/web/__test__/store/theme.test.ts @@ -154,7 +154,7 @@ describe('Theme Store', () => { ...style, getPropertyValue: (prop: string) => { if (prop === '--banner-gradient') { - return 'linear-gradient(90deg, rgba(0, 0, 0, 0) 0, rgba(0, 0, 0, 0.7) 90%)'; + return 'linear-gradient(90deg, rgba(0, 0, 0, 0) 0, rgba(0, 0, 0, 0.7) var(--banner-gradient-stop, 30%))'; } return style.getPropertyValue(prop); }, @@ -165,7 +165,7 @@ describe('Theme Store', () => { const store = createStore(); expect(store.bannerGradient).toBe( - 'background-image: linear-gradient(90deg, rgba(0, 0, 0, 0) 0, rgba(0, 0, 0, 0.7) 90%);' + 'background-image: linear-gradient(90deg, rgba(0, 0, 0, 0) 0, rgba(0, 0, 0, 0.7) var(--banner-gradient-stop, 30%));' ); vi.restoreAllMocks(); diff --git a/web/src/assets/main.css b/web/src/assets/main.css index dea0c1086f..599f231039 100644 --- a/web/src/assets/main.css +++ b/web/src/assets/main.css @@ -157,6 +157,17 @@ iframe#progressFrame { color-scheme: light; } +/* Banner gradient tuning */ +:root { + --banner-gradient-stop: 30%; +} + +@media (max-width: 768px) { + :root { + --banner-gradient-stop: 60%; + } +} + /* Header banner compatibility tweaks */ #header.image { background-position: center center; diff --git a/web/src/components/UserProfile.standalone.vue b/web/src/components/UserProfile.standalone.vue index 609ab906cd..21b998c6f4 100644 --- a/web/src/components/UserProfile.standalone.vue +++ b/web/src/components/UserProfile.standalone.vue @@ -94,7 +94,7 @@ onMounted(() => {