diff --git a/api/generated-schema.graphql b/api/generated-schema.graphql index 0dfe521f9e..28d1c106c7 100644 --- a/api/generated-schema.graphql +++ b/api/generated-schema.graphql @@ -944,6 +944,23 @@ input UpdateApiKeyInput { permissions: [AddPermissionInput!] } +"""Customization related mutations""" +type CustomizationMutations { + """Update the UI theme (writes dynamix.cfg)""" + setTheme( + """Theme to apply""" + theme: ThemeName! + ): Theme! +} + +"""The theme name""" +enum ThemeName { + azure + black + gray + white +} + """ Parity check related mutations, WIP, response types and functionaliy will change """ @@ -1042,14 +1059,6 @@ type Theme { headerSecondaryTextColor: String } -"""The theme name""" -enum ThemeName { - azure - black - gray - white -} - type ExplicitStatusItem { name: String! updateStatus: UpdateStatus! @@ -2449,6 +2458,7 @@ type Mutation { vm: VmMutations! parityCheck: ParityCheckMutations! apiKey: ApiKeyMutations! + customization: CustomizationMutations! rclone: RCloneMutations! createDockerFolder(name: String!, parentId: String, childrenIds: [String!]): ResolvedOrganizerV1! setDockerFolderChildren(folderId: String, childrenIds: [String!]!): ResolvedOrganizerV1! diff --git a/api/src/unraid-api/graph/resolvers/customization/customization.module.ts b/api/src/unraid-api/graph/resolvers/customization/customization.module.ts index 5e0233d88f..1df4bb4ba5 100644 --- a/api/src/unraid-api/graph/resolvers/customization/customization.module.ts +++ b/api/src/unraid-api/graph/resolvers/customization/customization.module.ts @@ -1,9 +1,11 @@ import { Module } from '@nestjs/common'; +import { CustomizationMutationsResolver } from '@app/unraid-api/graph/resolvers/customization/customization.mutations.resolver.js'; import { CustomizationResolver } from '@app/unraid-api/graph/resolvers/customization/customization.resolver.js'; import { CustomizationService } from '@app/unraid-api/graph/resolvers/customization/customization.service.js'; @Module({ - providers: [CustomizationService, CustomizationResolver], + providers: [CustomizationService, CustomizationResolver, CustomizationMutationsResolver], + exports: [CustomizationService], }) export class CustomizationModule {} diff --git a/api/src/unraid-api/graph/resolvers/customization/customization.mutations.resolver.ts b/api/src/unraid-api/graph/resolvers/customization/customization.mutations.resolver.ts new file mode 100644 index 0000000000..96e6b7727f --- /dev/null +++ b/api/src/unraid-api/graph/resolvers/customization/customization.mutations.resolver.ts @@ -0,0 +1,25 @@ +import { Args, ResolveField, Resolver } from '@nestjs/graphql'; + +import { AuthAction, Resource } from '@unraid/shared/graphql.model.js'; +import { UsePermissions } from '@unraid/shared/use-permissions.directive.js'; + +import { CustomizationService } from '@app/unraid-api/graph/resolvers/customization/customization.service.js'; +import { Theme, ThemeName } from '@app/unraid-api/graph/resolvers/customization/theme.model.js'; +import { CustomizationMutations } from '@app/unraid-api/graph/resolvers/mutation/mutation.model.js'; + +@Resolver(() => CustomizationMutations) +export class CustomizationMutationsResolver { + constructor(private readonly customizationService: CustomizationService) {} + + @ResolveField(() => Theme, { description: 'Update the UI theme (writes dynamix.cfg)' }) + @UsePermissions({ + action: AuthAction.UPDATE_ANY, + resource: Resource.CUSTOMIZATIONS, + }) + async setTheme( + @Args('theme', { type: () => ThemeName, description: 'Theme to apply' }) + theme: ThemeName + ): Promise { + return this.customizationService.setTheme(theme); + } +} diff --git a/api/src/unraid-api/graph/resolvers/customization/customization.service.ts b/api/src/unraid-api/graph/resolvers/customization/customization.service.ts index 5b27356e81..1ef1cce1cd 100644 --- a/api/src/unraid-api/graph/resolvers/customization/customization.service.ts +++ b/api/src/unraid-api/graph/resolvers/customization/customization.service.ts @@ -9,7 +9,9 @@ import * as ini from 'ini'; import { emcmd } from '@app/core/utils/clients/emcmd.js'; import { fileExists } from '@app/core/utils/files/file-exists.js'; +import { loadDynamixConfigFromDiskSync } from '@app/store/actions/load-dynamix-config-file.js'; import { getters, store } from '@app/store/index.js'; +import { updateDynamixConfig } from '@app/store/modules/dynamix.js'; import { ActivationCode, PublicPartnerInfo, @@ -466,4 +468,16 @@ export class CustomizationService implements OnModuleInit { showHeaderDescription: descriptionShow === 'yes', }; } + + public async setTheme(theme: ThemeName): Promise { + this.logger.log(`Updating theme to ${theme}`); + await this.updateCfgFile(this.configFile, 'display', { theme }); + + // Refresh in-memory store so subsequent reads get the new theme without a restart + const paths = getters.paths(); + const updatedConfig = loadDynamixConfigFromDiskSync(paths['dynamix-config']); + store.dispatch(updateDynamixConfig(updatedConfig)); + + return this.getTheme(); + } } diff --git a/api/src/unraid-api/graph/resolvers/mutation/mutation.model.ts b/api/src/unraid-api/graph/resolvers/mutation/mutation.model.ts index 73dad03e19..aae73aeebf 100644 --- a/api/src/unraid-api/graph/resolvers/mutation/mutation.model.ts +++ b/api/src/unraid-api/graph/resolvers/mutation/mutation.model.ts @@ -24,6 +24,11 @@ export class VmMutations {} }) export class ApiKeyMutations {} +@ObjectType({ + description: 'Customization related mutations', +}) +export class CustomizationMutations {} + @ObjectType({ description: 'Parity check related mutations, WIP, response types and functionaliy will change', }) @@ -54,6 +59,9 @@ export class RootMutations { @Field(() => ApiKeyMutations, { description: 'API Key related mutations' }) apiKey: ApiKeyMutations = new ApiKeyMutations(); + @Field(() => CustomizationMutations, { description: 'Customization related mutations' }) + customization: CustomizationMutations = new CustomizationMutations(); + @Field(() => ParityCheckMutations, { description: 'Parity check related mutations' }) parityCheck: ParityCheckMutations = new ParityCheckMutations(); diff --git a/api/src/unraid-api/graph/resolvers/mutation/mutation.resolver.ts b/api/src/unraid-api/graph/resolvers/mutation/mutation.resolver.ts index 42a9cb126a..7beca48bc8 100644 --- a/api/src/unraid-api/graph/resolvers/mutation/mutation.resolver.ts +++ b/api/src/unraid-api/graph/resolvers/mutation/mutation.resolver.ts @@ -3,6 +3,7 @@ import { Mutation, Resolver } from '@nestjs/graphql'; import { ApiKeyMutations, ArrayMutations, + CustomizationMutations, DockerMutations, ParityCheckMutations, RCloneMutations, @@ -37,6 +38,11 @@ export class RootMutationsResolver { return new ApiKeyMutations(); } + @Mutation(() => CustomizationMutations, { name: 'customization' }) + customization(): CustomizationMutations { + return new CustomizationMutations(); + } + @Mutation(() => RCloneMutations, { name: 'rclone' }) rclone(): RCloneMutations { return new RCloneMutations(); 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..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 @@ -209,6 +209,11 @@ 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'; + $vars['--theme-name'] = $theme ?: 'white'; + if ($theme === 'white') { if (!$textPrimary) { $vars['--header-text-primary'] = 'var(--inverse-text-color, #ffffff)'; @@ -218,19 +223,23 @@ 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( - 'linear-gradient(90deg, %s 0, %s 90%%)', + 'linear-gradient(90deg, %s 0, %s var(--banner-gradient-stop, 30%%))', $start, $end ); diff --git a/unraid-ui/src/composables/useTeleport.test.ts b/unraid-ui/src/composables/useTeleport.test.ts index 67308d3c4e..776dde1b78 100644 --- a/unraid-ui/src/composables/useTeleport.test.ts +++ b/unraid-ui/src/composables/useTeleport.test.ts @@ -1,12 +1,16 @@ -import useTeleport from '@/composables/useTeleport'; import { mount } from '@vue/test-utils'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { defineComponent } from 'vue'; +import { defineComponent, nextTick } from 'vue'; describe('useTeleport', () => { beforeEach(() => { + // Reset modules before each test to ensure fresh state + vi.resetModules(); // Clear the DOM before each test document.body.innerHTML = ''; + document.documentElement.classList.remove('dark'); + document.body.classList.remove('dark'); + document.documentElement.style.removeProperty('--theme-dark-mode'); vi.clearAllMocks(); }); @@ -16,16 +20,19 @@ describe('useTeleport', () => { if (virtualContainer) { virtualContainer.remove(); } - // Reset the module to clear the virtualModalContainer variable - vi.resetModules(); + document.documentElement.classList.remove('dark'); + document.body.classList.remove('dark'); + document.documentElement.style.removeProperty('--theme-dark-mode'); }); - it('should return teleportTarget ref with correct value', () => { + it('should return teleportTarget ref with correct value', async () => { + const useTeleport = (await import('@/composables/useTeleport')).default; const { teleportTarget } = useTeleport(); expect(teleportTarget.value).toBe('#unraid-api-modals-virtual'); }); - it('should create virtual container element on mount with correct properties', () => { + it('should create virtual container element on mount with correct properties', async () => { + const useTeleport = (await import('@/composables/useTeleport')).default; const TestComponent = defineComponent({ setup() { const { teleportTarget } = useTeleport(); @@ -39,6 +46,7 @@ describe('useTeleport', () => { // Mount the component mount(TestComponent); + await nextTick(); // After mount, virtual container should be created with correct properties const virtualContainer = document.getElementById('unraid-api-modals-virtual'); @@ -49,7 +57,8 @@ describe('useTeleport', () => { expect(virtualContainer?.parentElement).toBe(document.body); }); - it('should reuse existing virtual container within same test', () => { + it('should reuse existing virtual container within same test', async () => { + const useTeleport = (await import('@/composables/useTeleport')).default; // Manually create the container first const manualContainer = document.createElement('div'); manualContainer.id = 'unraid-api-modals-virtual'; @@ -68,10 +77,128 @@ describe('useTeleport', () => { // Mount component - should not create a new container mount(TestComponent); + await nextTick(); // Should still have only one container const containers = document.querySelectorAll('#unraid-api-modals-virtual'); expect(containers.length).toBe(1); expect(containers[0]).toBe(manualContainer); }); + + it('should apply dark class when dark mode is active via CSS variable', async () => { + const useTeleport = (await import('@/composables/useTeleport')).default; + const originalGetComputedStyle = window.getComputedStyle; + const getComputedStyleSpy = 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; + }); + + const TestComponent = defineComponent({ + setup() { + const { teleportTarget } = useTeleport(); + return { teleportTarget }; + }, + template: '
{{ teleportTarget }}
', + }); + + const wrapper = mount(TestComponent); + await nextTick(); + + const virtualContainer = document.getElementById('unraid-api-modals-virtual'); + expect(virtualContainer).toBeTruthy(); + expect(virtualContainer?.classList.contains('dark')).toBe(true); + + wrapper.unmount(); + getComputedStyleSpy.mockRestore(); + }); + + it('should not apply dark class when dark mode is inactive via CSS variable', async () => { + const useTeleport = (await import('@/composables/useTeleport')).default; + const originalGetComputedStyle = window.getComputedStyle; + const getComputedStyleSpy = 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 TestComponent = defineComponent({ + setup() { + const { teleportTarget } = useTeleport(); + return { teleportTarget }; + }, + template: '
{{ teleportTarget }}
', + }); + + const wrapper = mount(TestComponent); + await nextTick(); + + const virtualContainer = document.getElementById('unraid-api-modals-virtual'); + expect(virtualContainer).toBeTruthy(); + expect(virtualContainer?.classList.contains('dark')).toBe(false); + + wrapper.unmount(); + getComputedStyleSpy.mockRestore(); + }); + + it('should apply dark class when dark mode is active via documentElement class', async () => { + const useTeleport = (await import('@/composables/useTeleport')).default; + document.documentElement.classList.add('dark'); + + const originalGetComputedStyle = window.getComputedStyle; + const getComputedStyleSpy = 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 ''; + } + return style.getPropertyValue(prop); + }, + } as CSSStyleDeclaration; + } + return style; + }); + + const TestComponent = defineComponent({ + setup() { + const { teleportTarget } = useTeleport(); + return { teleportTarget }; + }, + template: '
{{ teleportTarget }}
', + }); + + const wrapper = mount(TestComponent); + await nextTick(); + + const virtualContainer = document.getElementById('unraid-api-modals-virtual'); + expect(virtualContainer).toBeTruthy(); + expect(virtualContainer?.classList.contains('dark')).toBe(true); + + wrapper.unmount(); + getComputedStyleSpy.mockRestore(); + document.documentElement.classList.remove('dark'); + }); }); diff --git a/unraid-ui/src/composables/useTeleport.ts b/unraid-ui/src/composables/useTeleport.ts index d0ec36663e..5c50ba1d68 100644 --- a/unraid-ui/src/composables/useTeleport.ts +++ b/unraid-ui/src/composables/useTeleport.ts @@ -1,15 +1,24 @@ +import { isDarkModeActive } from '@/lib/utils'; import { onMounted, ref } from 'vue'; let virtualModalContainer: HTMLDivElement | null = null; const ensureVirtualContainer = () => { if (!virtualModalContainer) { - virtualModalContainer = document.createElement('div'); - virtualModalContainer.id = 'unraid-api-modals-virtual'; - virtualModalContainer.className = 'unapi'; - virtualModalContainer.style.position = 'relative'; - virtualModalContainer.style.zIndex = '999999'; - document.body.appendChild(virtualModalContainer); + const existing = document.getElementById('unraid-api-modals-virtual'); + if (existing) { + virtualModalContainer = existing as HTMLDivElement; + } else { + virtualModalContainer = document.createElement('div'); + virtualModalContainer.id = 'unraid-api-modals-virtual'; + virtualModalContainer.className = 'unapi'; + virtualModalContainer.style.position = 'relative'; + virtualModalContainer.style.zIndex = '999999'; + if (isDarkModeActive()) { + virtualModalContainer.classList.add('dark'); + } + document.body.appendChild(virtualModalContainer); + } } return virtualModalContainer; }; diff --git a/unraid-ui/src/lib/utils.test.ts b/unraid-ui/src/lib/utils.test.ts new file mode 100644 index 0000000000..0f2dff1c46 --- /dev/null +++ b/unraid-ui/src/lib/utils.test.ts @@ -0,0 +1,193 @@ +import { isDarkModeActive } from '@/lib/utils'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +describe('isDarkModeActive', () => { + const originalGetComputedStyle = window.getComputedStyle; + const originalDocumentElement = document.documentElement; + const originalBody = document.body; + + beforeEach(() => { + document.documentElement.classList.remove('dark'); + document.body.classList.remove('dark'); + document.documentElement.style.removeProperty('--theme-dark-mode'); + document.querySelectorAll('.unapi').forEach((el) => el.classList.remove('dark')); + vi.clearAllMocks(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + document.documentElement.classList.remove('dark'); + document.body.classList.remove('dark'); + document.documentElement.style.removeProperty('--theme-dark-mode'); + document.querySelectorAll('.unapi').forEach((el) => el.classList.remove('dark')); + }); + + describe('CSS variable detection', () => { + it('should return true when CSS variable is set to "1"', () => { + 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; + }); + + expect(isDarkModeActive()).toBe(true); + }); + + it('should return false when CSS variable is set to "0"', () => { + 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; + }); + + expect(isDarkModeActive()).toBe(false); + }); + + it('should return false when CSS variable is explicitly "0" even if dark class exists', () => { + document.documentElement.classList.add('dark'); + document.body.classList.add('dark'); + + 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; + }); + + expect(isDarkModeActive()).toBe(false); + }); + }); + + describe('ClassList detection fallback', () => { + it('should return true when documentElement has dark class and CSS variable is not set', () => { + document.documentElement.classList.add('dark'); + + 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 ''; + } + return style.getPropertyValue(prop); + }, + } as CSSStyleDeclaration; + } + return style; + }); + + expect(isDarkModeActive()).toBe(true); + }); + + it('should return true when body has dark class and CSS variable is not set', () => { + document.body.classList.add('dark'); + + 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 ''; + } + return style.getPropertyValue(prop); + }, + } as CSSStyleDeclaration; + } + return style; + }); + + expect(isDarkModeActive()).toBe(true); + }); + + it('should return true when .unapi.dark element exists and CSS variable is not set', () => { + const unapiElement = document.createElement('div'); + unapiElement.className = 'unapi dark'; + document.body.appendChild(unapiElement); + + 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 ''; + } + return style.getPropertyValue(prop); + }, + } as CSSStyleDeclaration; + } + return style; + }); + + expect(isDarkModeActive()).toBe(true); + + unapiElement.remove(); + }); + + it('should return false when no dark indicators are present', () => { + 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 ''; + } + return style.getPropertyValue(prop); + }, + } as CSSStyleDeclaration; + } + return style; + }); + + expect(isDarkModeActive()).toBe(false); + }); + }); + + describe('SSR/Node environment', () => { + it('should return false when document is undefined', () => { + const originalDocument = global.document; + // @ts-expect-error - intentionally removing document for SSR test + global.document = undefined; + + expect(isDarkModeActive()).toBe(false); + + global.document = originalDocument; + }); + }); +}); diff --git a/unraid-ui/src/lib/utils.ts b/unraid-ui/src/lib/utils.ts index 5a6953152e..7ae2a4e20e 100644 --- a/unraid-ui/src/lib/utils.ts +++ b/unraid-ui/src/lib/utils.ts @@ -54,3 +54,17 @@ export class Markdown { return Markdown.instance.parse(markdownContent); } } + +export const isDarkModeActive = (): boolean => { + if (typeof document === 'undefined') return false; + + const cssVar = getComputedStyle(document.documentElement).getPropertyValue('--theme-dark-mode').trim(); + if (cssVar === '1') return true; + if (cssVar === '0') return false; + + if (document.documentElement.classList.contains('dark')) return true; + if (document.body?.classList.contains('dark')) return true; + if (document.querySelector('.unapi.dark')) return true; + + return false; +}; diff --git a/web/.prettierignore b/web/.prettierignore index dd58567ac2..a815be305e 100644 --- a/web/.prettierignore +++ b/web/.prettierignore @@ -2,3 +2,4 @@ auto-imports.d.ts components.d.ts composables/gql/ src/composables/gql/ +dist/ diff --git a/web/__test__/components/ColorSwitcher.test.ts b/web/__test__/components/ColorSwitcher.test.ts index 0d010e00cd..c8e110fec0 100644 --- a/web/__test__/components/ColorSwitcher.test.ts +++ b/web/__test__/components/ColorSwitcher.test.ts @@ -22,6 +22,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(), + }), })); // Explicitly mock @unraid/ui to ensure we use the actual components @@ -54,6 +61,11 @@ describe('ColorSwitcher', () => { beforeEach(() => { vi.useFakeTimers(); + + // Set CSS variables for theme store + document.documentElement.style.setProperty('--theme-dark-mode', '0'); + document.documentElement.style.setProperty('--banner-gradient', ''); + const pinia = createTestingPinia({ createSpy: vi.fn }); setActivePinia(pinia); themeStore = useThemeStore(); @@ -69,8 +81,12 @@ describe('ColorSwitcher', () => { afterEach(() => { vi.runOnlyPendingTimers(); vi.useRealTimers(); - document.body.removeChild(modalDiv); - consoleWarnSpy.mockRestore(); + if (modalDiv && modalDiv.parentNode) { + modalDiv.parentNode.removeChild(modalDiv); + } + if (consoleWarnSpy) { + consoleWarnSpy.mockRestore(); + } }); it('renders all form elements correctly', () => { diff --git a/web/__test__/components/UserProfile.test.ts b/web/__test__/components/UserProfile.test.ts index ef4d2a53e6..6bb6ee6c26 100644 --- a/web/__test__/components/UserProfile.test.ts +++ b/web/__test__/components/UserProfile.test.ts @@ -53,6 +53,18 @@ vi.mock('@unraid/ui', () => ({ props: ['variant', 'size'], }, cn: (...classes: string[]) => classes.filter(Boolean).join(' '), + isDarkModeActive: vi.fn(() => { + if (typeof document === 'undefined') return false; + const cssVar = getComputedStyle(document.documentElement) + .getPropertyValue('--theme-dark-mode') + .trim(); + if (cssVar === '1') return true; + if (cssVar === '0') return false; + if (document.documentElement.classList.contains('dark')) return true; + if (document.body?.classList.contains('dark')) return true; + if (document.querySelector('.unapi.dark')) return true; + return false; + }), })); const mockWatcher = vi.fn(); @@ -182,26 +194,33 @@ describe('UserProfile.standalone.vue', () => { createSpy: vi.fn, initialState: { server: { ...initialServerData }, - theme: { - theme: { - name: 'default', - banner: true, - bannerGradient: true, - descriptionShow: true, - textColor: '', - metaColor: '', - bgColor: '', - }, - bannerGradient: 'linear-gradient(to right, #ff0000, #0000ff)', - }, }, stubActions: false, }); setActivePinia(pinia); serverStore = useServerStore(); + + // Set CSS variables directly on document element for theme store + document.documentElement.style.setProperty('--theme-dark-mode', '0'); + document.documentElement.style.setProperty( + '--banner-gradient', + 'linear-gradient(90deg, rgba(0, 0, 0, 0) 0, rgba(0, 0, 0, 0.7) var(--banner-gradient-stop, 30%))' + ); + themeStore = useThemeStore(); + // Set the theme using setTheme method + themeStore.setTheme({ + name: 'white', + banner: true, + bannerGradient: true, + descriptionShow: true, + textColor: '', + metaColor: '', + bgColor: '', + }); + // Override the setServer method to prevent console logging vi.spyOn(serverStore, 'setServer').mockImplementation((server) => { Object.assign(serverStore, server); @@ -326,7 +345,7 @@ describe('UserProfile.standalone.vue', () => { expect(themeStore.theme?.descriptionShow).toBe(true); serverStore.description = initialServerData.description!; - themeStore.theme!.descriptionShow = true; + themeStore.setTheme({ ...themeStore.theme, descriptionShow: true }); await wrapper.vm.$nextTick(); // Look for the description in a span element with v-html directive @@ -334,14 +353,14 @@ describe('UserProfile.standalone.vue', () => { expect(descriptionElement.exists()).toBe(true); expect(descriptionElement.html()).toContain(initialServerData.description); - themeStore.theme!.descriptionShow = false; + themeStore.setTheme({ ...themeStore.theme, descriptionShow: false }); await wrapper.vm.$nextTick(); // When descriptionShow is false, the element should not exist descriptionElement = wrapper.find('span.hidden.text-center.text-base'); expect(descriptionElement.exists()).toBe(false); - themeStore.theme!.descriptionShow = true; + themeStore.setTheme({ ...themeStore.theme, descriptionShow: true }); await wrapper.vm.$nextTick(); descriptionElement = wrapper.find('span.hidden.text-center.text-base'); @@ -361,23 +380,29 @@ describe('UserProfile.standalone.vue', () => { it('conditionally renders banner based on theme store', async () => { const bannerSelector = 'div.absolute.z-0'; - themeStore.theme = { - ...themeStore.theme!, + themeStore.setTheme({ + ...themeStore.theme, banner: true, bannerGradient: true, - }; + }); await wrapper.vm.$nextTick(); expect(themeStore.bannerGradient).toContain('background-image: linear-gradient'); expect(wrapper.find(bannerSelector).exists()).toBe(true); - themeStore.theme!.bannerGradient = false; + themeStore.setTheme({ + ...themeStore.theme, + bannerGradient: false, + }); await wrapper.vm.$nextTick(); expect(themeStore.bannerGradient).toBeUndefined(); expect(wrapper.find(bannerSelector).exists()).toBe(false); - themeStore.theme!.bannerGradient = true; + themeStore.setTheme({ + ...themeStore.theme, + bannerGradient: true, + }); await wrapper.vm.$nextTick(); expect(themeStore.bannerGradient).toContain('background-image: linear-gradient'); diff --git a/web/__test__/components/Wrapper/mount-engine.test.ts b/web/__test__/components/Wrapper/mount-engine.test.ts index ec4fd3e5da..31737ff8c4 100644 --- a/web/__test__/components/Wrapper/mount-engine.test.ts +++ b/web/__test__/components/Wrapper/mount-engine.test.ts @@ -21,6 +21,21 @@ vi.mock('@nuxt/ui/vue-plugin', () => ({ }, })); +vi.mock('@unraid/ui', () => ({ + isDarkModeActive: vi.fn(() => { + if (typeof document === 'undefined') return false; + const cssVar = getComputedStyle(document.documentElement) + .getPropertyValue('--theme-dark-mode') + .trim(); + if (cssVar === '1') return true; + if (cssVar === '0') return false; + if (document.documentElement.classList.contains('dark')) return true; + if (document.body?.classList.contains('dark')) return true; + if (document.querySelector('.unapi.dark')) return true; + return false; + }), +})); + // Mock component registry const mockComponentMappings: ComponentMapping[] = []; vi.mock('~/components/Wrapper/component-registry', () => ({ diff --git a/web/__test__/mocks/ui-components.ts b/web/__test__/mocks/ui-components.ts index 972824d087..aa6bc6dbb3 100644 --- a/web/__test__/mocks/ui-components.ts +++ b/web/__test__/mocks/ui-components.ts @@ -94,5 +94,17 @@ vi.mock('@unraid/ui', () => ({ name: 'ResponsiveModalTitle', template: '
', }, + isDarkModeActive: vi.fn(() => { + if (typeof document === 'undefined') return false; + const cssVar = getComputedStyle(document.documentElement) + .getPropertyValue('--theme-dark-mode') + .trim(); + if (cssVar === '1') return true; + if (cssVar === '0') return false; + if (document.documentElement.classList.contains('dark')) return true; + if (document.body?.classList.contains('dark')) return true; + if (document.querySelector('.unapi.dark')) return true; + return false; + }), // Add other UI components as needed })); diff --git a/web/__test__/store/theme.test.ts b/web/__test__/store/theme.test.ts index 0a574016c7..a25d51c4a3 100644 --- a/web/__test__/store/theme.test.ts +++ b/web/__test__/store/theme.test.ts @@ -18,6 +18,28 @@ 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(), + }), +})); + +vi.mock('@unraid/ui', () => ({ + isDarkModeActive: vi.fn(() => { + if (typeof document === 'undefined') return false; + const cssVar = getComputedStyle(document.documentElement) + .getPropertyValue('--theme-dark-mode') + .trim(); + if (cssVar === '1') return true; + if (cssVar === '0') return false; + if (document.documentElement.classList.contains('dark')) return true; + if (document.body?.classList.contains('dark')) return true; + if (document.querySelector('.unapi.dark')) return true; + return false; + }), })); describe('Theme Store', () => { @@ -43,6 +65,11 @@ describe('Theme Store', () => { document.body.style.cssText = ''; document.documentElement.classList.add = vi.fn(); document.documentElement.classList.remove = vi.fn(); + document.documentElement.style.removeProperty('--theme-dark-mode'); + document.documentElement.style.removeProperty('--theme-name'); + document.documentElement.classList.remove('dark'); + document.body.classList.remove('dark'); + document.querySelectorAll('.unapi').forEach((el) => el.classList.remove('dark')); vi.spyOn(window, 'requestAnimationFrame').mockImplementation((cb) => { cb(0); @@ -55,7 +82,13 @@ describe('Theme Store', () => { afterEach(() => { store?.$dispose(); store = undefined; - app?.unmount(); + if (app) { + try { + app.unmount(); + } catch { + // App was not mounted, ignore + } + } app = undefined; document.body.classList.add = originalAddClassFn; @@ -90,44 +123,41 @@ describe('Theme Store', () => { expect(store.activeColorVariables).toEqual(defaultColors.white); }); - it('should compute darkMode correctly', () => { + it('should compute darkMode from CSS variable when set to 1', () => { + document.documentElement.style.setProperty('--theme-dark-mode', '1'); const store = createStore(); + expect(store.darkMode).toBe(true); + }); + it('should compute darkMode from CSS variable when set to 0', () => { + document.documentElement.style.setProperty('--theme-dark-mode', '0'); + const store = createStore(); expect(store.darkMode).toBe(false); + }); - store.setTheme({ ...store.theme, name: 'black' }); - expect(store.darkMode).toBe(true); - - store.setTheme({ ...store.theme, name: 'gray' }); - expect(store.darkMode).toBe(true); + it('should compute bannerGradient from CSS variable when set', async () => { + document.documentElement.style.setProperty('--theme-dark-mode', '0'); + // Set the gradient with the resolved value (not nested var()) since getComputedStyle resolves it + document.documentElement.style.setProperty( + '--banner-gradient', + 'linear-gradient(90deg, rgba(0, 0, 0, 0) 0, rgba(0, 0, 0, 0.7) 30%)' + ); - store.setTheme({ ...store.theme, name: 'white' }); + const store = createStore(); + store.setTheme({ banner: true, bannerGradient: true }); + await nextTick(); + expect(store.theme.banner).toBe(true); + expect(store.theme.bannerGradient).toBe(true); expect(store.darkMode).toBe(false); + expect(store.bannerGradient).toBe( + 'background-image: linear-gradient(90deg, rgba(0, 0, 0, 0) 0, rgba(0, 0, 0, 0.7) 30%);' + ); }); - it('should compute bannerGradient correctly', () => { + it('should return undefined when bannerGradient CSS variable is not set', () => { + document.documentElement.style.removeProperty('--banner-gradient'); const store = createStore(); - expect(store.bannerGradient).toBeUndefined(); - - store.setTheme({ - ...store.theme, - banner: true, - bannerGradient: true, - }); - expect(store.bannerGradient).toMatchInlineSnapshot( - `"background-image: linear-gradient(90deg, rgba(0, 0, 0, 0) 0, var(--header-background-color) 90%);"` - ); - - store.setTheme({ - ...store.theme, - banner: true, - bannerGradient: true, - bgColor: '#123456', - }); - expect(store.bannerGradient).toMatchInlineSnapshot( - `"background-image: linear-gradient(90deg, var(--header-gradient-start) 0, var(--header-gradient-end) 90%);"` - ); }); }); @@ -157,12 +187,16 @@ describe('Theme Store', () => { await nextTick(); expect(document.body.classList.add).toHaveBeenCalledWith('dark'); + expect(document.documentElement.classList.add).toHaveBeenCalledWith('dark'); + expect(store.darkMode).toBe(true); store.setTheme({ ...store.theme, name: 'white' }); await nextTick(); expect(document.body.classList.remove).toHaveBeenCalledWith('dark'); + expect(document.documentElement.classList.remove).toHaveBeenCalledWith('dark'); + expect(store.darkMode).toBe(false); }); it('should update activeColorVariables when theme changes', async () => { @@ -195,33 +229,22 @@ describe('Theme Store', () => { expect(document.documentElement.classList.add).toHaveBeenCalledWith('dark'); expect(document.body.classList.add).toHaveBeenCalledWith('dark'); + expect(store.darkMode).toBe(true); }); - it('should apply dark mode classes to all .unapi elements', async () => { + it('should update darkMode reactively when theme changes', 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'); + expect(store.darkMode).toBe(false); store.setTheme({ ...store.theme, - name: 'black', + name: 'gray', }); await nextTick(); - expect(addSpy1).toHaveBeenCalledWith('dark'); - expect(addSpy2).toHaveBeenCalledWith('dark'); + expect(store.darkMode).toBe(true); store.setTheme({ ...store.theme, @@ -230,11 +253,40 @@ describe('Theme Store', () => { await nextTick(); - expect(removeSpy1).toHaveBeenCalledWith('dark'); - expect(removeSpy2).toHaveBeenCalledWith('dark'); + expect(store.darkMode).toBe(false); + }); + + 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'; + } + if (prop === '--theme-name') { + return 'black'; + } + return style.getPropertyValue(prop); + }, + } as CSSStyleDeclaration; + } + return style; + }); + + document.documentElement.style.setProperty('--theme-dark-mode', '1'); + const store = createStore(); + + // Should have added dark class to documentElement and body + expect(document.documentElement.classList.add).toHaveBeenCalledWith('dark'); + expect(document.body.classList.add).toHaveBeenCalledWith('dark'); + expect(store.darkMode).toBe(true); - document.body.removeChild(unapiElement1); - document.body.removeChild(unapiElement2); + 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/DevThemeSwitcher.mutation.ts b/web/src/components/DevThemeSwitcher.mutation.ts new file mode 100644 index 0000000000..ac6fd904be --- /dev/null +++ b/web/src/components/DevThemeSwitcher.mutation.ts @@ -0,0 +1,17 @@ +import { graphql } from '~/composables/gql/gql'; + +export const SET_THEME_MUTATION = graphql(/* GraphQL */ ` + mutation setTheme($theme: ThemeName!) { + customization { + setTheme(theme: $theme) { + name + showBannerImage + showBannerGradient + headerBackgroundColor + showHeaderDescription + headerPrimaryTextColor + headerSecondaryTextColor + } + } + } +`); diff --git a/web/src/components/DevThemeSwitcher.standalone.vue b/web/src/components/DevThemeSwitcher.standalone.vue index 47ac5cdbaa..484b86e54b 100644 --- a/web/src/components/DevThemeSwitcher.standalone.vue +++ b/web/src/components/DevThemeSwitcher.standalone.vue @@ -1,66 +1,139 @@