diff --git a/web/__test__/components/HeaderServerStatus.test.ts b/web/__test__/components/HeaderServerStatus.test.ts
new file mode 100644
index 0000000000..cb3e194648
--- /dev/null
+++ b/web/__test__/components/HeaderServerStatus.test.ts
@@ -0,0 +1,93 @@
+import { nextTick } from 'vue';
+import { createPinia, setActivePinia } from 'pinia';
+import { mount } from '@vue/test-utils';
+
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+
+import type { VueWrapper } from '@vue/test-utils';
+
+import ServerStatus from '~/components/UserProfile/ServerStatus.vue';
+import UptimeExpire from '~/components/UserProfile/UptimeExpire.vue';
+import { useServerStore } from '~/store/server';
+import { createTestI18n } from '../utils/i18n';
+
+vi.mock('~/store/server', async () => {
+ const { defineStore } = await import('pinia');
+ return {
+ useServerStore: defineStore('server', {
+ state: () => ({
+ state: 'LIFETIME',
+ uptime: Date.now() - 9 * 86400000,
+ expireTime: Date.now() + 14 * 86400000,
+ dateTimeFormat: { date: '%c', time: '%R' },
+ }),
+ }),
+ };
+});
+
+vi.mock('@unraid/ui', () => {
+ const slot = { template: '
' };
+ return {
+ cn: (...values: string[]) => values.filter(Boolean).join(' '),
+ Tooltip: slot,
+ TooltipContent: slot,
+ TooltipProvider: slot,
+ TooltipTrigger: slot,
+ };
+});
+
+describe('header uptime and expiry states', () => {
+ let wrapper: VueWrapper | undefined;
+
+ beforeEach(() => {
+ vi.useFakeTimers();
+ vi.setSystemTime(new Date('2026-10-10T12:00:00Z'));
+ setActivePinia(createPinia());
+ });
+
+ afterEach(() => {
+ wrapper?.unmount();
+ vi.clearAllTimers();
+ vi.useRealTimers();
+ });
+
+ it('refreshes the compact timestamp when a running server changes to a trial or expired trial', async () => {
+ wrapper = mount(ServerStatus, {
+ global: { plugins: [createTestI18n()], stubs: { UpcServerState: true } },
+ });
+ const server = useServerStore();
+ expect(wrapper.text()).toContain('Uptime 9 days');
+
+ server.state = 'TRIAL';
+ await nextTick();
+ expect(wrapper.text()).toContain('Expires in 14 days');
+ expect(wrapper.text()).not.toContain('Expires in 9 days');
+
+ server.expireTime = Date.now() + 17 * 86400000;
+ await nextTick();
+ expect(wrapper.text()).toContain('Expires in 17 days');
+
+ server.state = 'ENOCONN';
+ await nextTick();
+ expect(wrapper.text()).toContain('Expires in 17 days');
+
+ server.state = 'EEXPIRED';
+ server.expireTime = Date.now() - 2 * 86400000;
+ await nextTick();
+ expect(wrapper.text()).toContain('Expired 2 days');
+ });
+
+ it('keeps compact expiry to its largest unit while preserving the full expiry display', () => {
+ const server = useServerStore();
+ server.state = 'TRIAL';
+ server.expireTime = Date.now() + 14 * 86400000 + 2 * 3600000 + 3 * 60000;
+ wrapper = mount(UptimeExpire, {
+ props: { shortText: true },
+ global: { plugins: [createTestI18n()] },
+ });
+ expect(wrapper.text()).toBe('Expires in 14 days');
+ wrapper.unmount();
+ wrapper = mount(UptimeExpire, { global: { plugins: [createTestI18n()] } });
+ expect(wrapper.text()).toContain('14 days 2 hours 3 minutes');
+ });
+});
diff --git a/web/__test__/components/HeaderThemes.test.ts b/web/__test__/components/HeaderThemes.test.ts
index 88872ff65a..762905501c 100644
--- a/web/__test__/components/HeaderThemes.test.ts
+++ b/web/__test__/components/HeaderThemes.test.ts
@@ -1,4 +1,7 @@
-import { ref } from 'vue';
+import { readFileSync } from 'node:fs';
+import { URL as NodeURL } from 'node:url';
+
+import { nextTick, ref } from 'vue';
import { setActivePinia } from 'pinia';
import { provideApolloClient } from '@vue/apollo-composable';
import { mount } from '@vue/test-utils';
@@ -88,7 +91,7 @@ const THEMES = [
const layoutSignature = (wrapper: VueWrapper) => {
const root = wrapper.get('#UnraidHeader');
const gradient = root.find('.unraid-banner-gradient-layer');
- const logoSvg = root.find('.uh-logo-block a svg');
+ const logoSvg = root.find('.uh-logo-block a :is(svg, img)');
return {
gradientLayer: gradient.exists() ? [...gradient.classes()].sort().join(' ') : null,
logoSvg: logoSvg.exists() ? [...logoSvg.classes()].sort().join(' ') : null,
@@ -185,6 +188,19 @@ describe('Header.standalone.vue theme layout', () => {
}
);
+ it('embeds the original WebGUI logo asset without reconstructing its paths', () => {
+ const image = mountWithTheme('white').get('.uh-logo-block img');
+ const original = readFileSync(
+ new NodeURL('../../../api/dev/webGui/images/UN-logotype-gradient.svg', import.meta.url),
+ 'utf8'
+ );
+ expect(decodeURIComponent(image.attributes('src')!.replace('data:image/svg+xml,', ''))).toBe(
+ original
+ );
+ expect(image.attributes('width')).toBe('222.36');
+ expect(image.attributes('height')).toBe('39.04');
+ });
+
it.each(['gradient', 'theme'] as const)('renders the %s header logo style', (logoStyle) => {
expect(layoutSignature(mountWithTheme('white', { logoStyle })).logoSvg).toMatchSnapshot();
});
@@ -199,7 +215,7 @@ describe('Header.standalone.vue theme layout', () => {
it.each(THEMES.map((theme) => [theme.name, theme] as const))(
'keeps the logo a fixed size on the %s theme so the boot placeholder cannot resize on mount',
(_name, theme) => {
- const logoSvg = mountWithTheme(theme.name).get('.uh-logo-block a svg');
+ const logoSvg = mountWithTheme(theme.name).get('.uh-logo-block a :is(svg, img)');
expect(logoSvg.classes()).toContain('shrink-0');
expect(logoSvg.classes()).not.toContain('max-w-full');
expect(logoSvg.classes()).toContain('w-[14rem]');
@@ -207,19 +223,33 @@ describe('Header.standalone.vue theme layout', () => {
}
);
- /**
- * Regression guard: the banner gradient must stay within the legacy header's
- * `min(30%, 320px)` edge width. A bare `left-[55%]` covered the right 45%
- * (~576px at 1280px, ~1.8x legacy) and visibly obscured users' banner images.
- */
- it('scopes the banner gradient to the legacy edge width', () => {
- const gradient = mountWithTheme('white').get('.unraid-banner-gradient-layer');
- expect(gradient.classes()).toContain('w-[min(30%,320px)]');
- expect(gradient.classes()).toContain('right-0');
- expect(gradient.classes()).not.toContain('left-[55%]');
+ it('enables the header fade only while both banner and fade are enabled', async () => {
+ const style = document.documentElement.style;
+ const previousGradient = style.getPropertyValue('--banner-gradient');
+ style.setProperty('--banner-gradient', 'linear-gradient(90deg, transparent, #1c1b1b)');
+ try {
+ const wrapper = mountWithTheme('white');
+ const themeStore = useThemeStore();
+ expect(wrapper.classes()).toContain('uh-banner-gradient-enabled');
+
+ themeStore.setTheme({ bannerGradient: false });
+ await nextTick();
+ expect(wrapper.classes()).not.toContain('uh-banner-gradient-enabled');
+
+ themeStore.setTheme({ bannerGradient: true });
+ await nextTick();
+ expect(wrapper.classes()).toContain('uh-banner-gradient-enabled');
+
+ themeStore.setTheme({ banner: false });
+ await nextTick();
+ expect(wrapper.classes()).not.toContain('uh-banner-gradient-enabled');
+ } finally {
+ if (previousGradient) style.setProperty('--banner-gradient', previousGradient);
+ else style.removeProperty('--banner-gradient');
+ }
});
- it('omits the banner gradient styling hook only when the theme disables it', () => {
+ it('disables the backdrop without a banner', () => {
const themeStore = useThemeStore();
themeStore.setTheme({
name: 'white',
@@ -235,9 +265,8 @@ describe('Header.standalone.vue theme layout', () => {
global: { plugins: [pinia], stubs },
});
wrappers.push(wrapper);
- // The layer is always present; `--banner-gradient` is null when disabled, so
- // it self-gates in CSS rather than by conditional rendering.
expect(wrapper.find('.unraid-banner-gradient-layer').exists()).toBe(true);
+ expect(wrapper.classes()).not.toContain('uh-banner-gradient-enabled');
expect(wrapper.find('.uh-meta-over-banner').exists()).toBe(false);
});
});
diff --git a/web/__test__/components/__snapshots__/HeaderThemes.test.ts.snap b/web/__test__/components/__snapshots__/HeaderThemes.test.ts.snap
index acc7c2ec62..b16733f77f 100644
--- a/web/__test__/components/__snapshots__/HeaderThemes.test.ts.snap
+++ b/web/__test__/components/__snapshots__/HeaderThemes.test.ts.snap
@@ -2,8 +2,8 @@
exports[`Header.standalone.vue theme layout > renders a consistent layout on the azure theme 1`] = `
{
- "gradientLayer": "absolute inset-y-0 pointer-events-none right-0 unraid-banner-gradient-layer w-[min(30%,320px)] z-0",
- "logoSvg": "h-auto max-h-[3rem] shrink-0 w-[14rem] xs:w-[16rem]",
+ "gradientLayer": "absolute inset-y-0 pointer-events-none unraid-banner-gradient-layer z-0",
+ "logoSvg": "h-auto max-h-[3rem] object-contain shrink-0 w-[14rem] xs:w-[16rem]",
"metaOverBanner": true,
"regions": {
"arrayUsage": true,
@@ -17,8 +17,8 @@ exports[`Header.standalone.vue theme layout > renders a consistent layout on the
exports[`Header.standalone.vue theme layout > renders a consistent layout on the black theme 1`] = `
{
- "gradientLayer": "absolute inset-y-0 pointer-events-none right-0 unraid-banner-gradient-layer w-[min(30%,320px)] z-0",
- "logoSvg": "h-auto max-h-[3rem] shrink-0 w-[14rem] xs:w-[16rem]",
+ "gradientLayer": "absolute inset-y-0 pointer-events-none unraid-banner-gradient-layer z-0",
+ "logoSvg": "h-auto max-h-[3rem] object-contain shrink-0 w-[14rem] xs:w-[16rem]",
"metaOverBanner": true,
"regions": {
"arrayUsage": false,
@@ -32,8 +32,8 @@ exports[`Header.standalone.vue theme layout > renders a consistent layout on the
exports[`Header.standalone.vue theme layout > renders a consistent layout on the gray theme 1`] = `
{
- "gradientLayer": "absolute inset-y-0 pointer-events-none right-0 unraid-banner-gradient-layer w-[min(30%,320px)] z-0",
- "logoSvg": "h-auto max-h-[3rem] shrink-0 w-[14rem] xs:w-[16rem]",
+ "gradientLayer": "absolute inset-y-0 pointer-events-none unraid-banner-gradient-layer z-0",
+ "logoSvg": "h-auto max-h-[3rem] object-contain shrink-0 w-[14rem] xs:w-[16rem]",
"metaOverBanner": true,
"regions": {
"arrayUsage": true,
@@ -47,8 +47,8 @@ exports[`Header.standalone.vue theme layout > renders a consistent layout on the
exports[`Header.standalone.vue theme layout > renders a consistent layout on the white theme 1`] = `
{
- "gradientLayer": "absolute inset-y-0 pointer-events-none right-0 unraid-banner-gradient-layer w-[min(30%,320px)] z-0",
- "logoSvg": "h-auto max-h-[3rem] shrink-0 w-[14rem] xs:w-[16rem]",
+ "gradientLayer": "absolute inset-y-0 pointer-events-none unraid-banner-gradient-layer z-0",
+ "logoSvg": "h-auto max-h-[3rem] object-contain shrink-0 w-[14rem] xs:w-[16rem]",
"metaOverBanner": true,
"regions": {
"arrayUsage": false,
@@ -60,6 +60,6 @@ exports[`Header.standalone.vue theme layout > renders a consistent layout on the
}
`;
-exports[`Header.standalone.vue theme layout > renders the gradient header logo style 1`] = `"h-auto max-h-[3rem] shrink-0 w-[14rem] xs:w-[16rem]"`;
+exports[`Header.standalone.vue theme layout > renders the gradient header logo style 1`] = `"h-auto max-h-[3rem] object-contain shrink-0 w-[14rem] xs:w-[16rem]"`;
exports[`Header.standalone.vue theme layout > renders the theme header logo style 1`] = `"h-auto max-h-[3rem] shrink-0 text-header-text-primary w-[14rem] xs:w-[16rem]"`;
diff --git a/web/src/assets/UN-logotype-gradient.svg b/web/src/assets/UN-logotype-gradient.svg
new file mode 100644
index 0000000000..fe08730371
--- /dev/null
+++ b/web/src/assets/UN-logotype-gradient.svg
@@ -0,0 +1 @@
+
diff --git a/web/src/components/Brand/Avatar.vue b/web/src/components/Brand/Avatar.vue
index 4be7462d89..7b5ea9cb83 100644
--- a/web/src/components/Brand/Avatar.vue
+++ b/web/src/components/Brand/Avatar.vue
@@ -22,11 +22,7 @@ const { avatar, connectPluginInstalled, registered, username } = storeToRefs(ser
class="absolute inset-0 z-10 h-9 w-9 overflow-hidden rounded-full object-cover"
/>
-
+
diff --git a/web/src/components/Header.standalone.vue b/web/src/components/Header.standalone.vue
index 7630aad806..d68978cb24 100644
--- a/web/src/components/Header.standalone.vue
+++ b/web/src/components/Header.standalone.vue
@@ -29,12 +29,8 @@ import { useThemeStore } from '~/store/theme';
* multi-component header (`unraid-header-os-version` + `unraid-user-profile`) is
* rendered instead.
*
- * Layout is a CSS grid so a single `actions` block (bell + account menu) can be
- * repositioned across the breakpoint without duplicating stateful components:
- * - mobile: app-style — logo + actions share the top row, then version, then
- * status, then name.
- * - sm+: two columns — logo/version on the left, status over name+actions on the
- * right (the prior desktop look).
+ * A single grid keeps stateful controls mounted once. On mobile the version and
+ * update notice span the header width instead of competing with account actions.
*/
export interface Props {
server?: Server | string;
@@ -73,10 +69,6 @@ const theme = computed(() => themeStore.theme);
const logoStyle = computed(() =>
props.headerLogoStyle === 'theme' ? 'theme' : 'gradient'
);
-// On mobile the meta row spans full width over the banner image, so its text
-// needs its own legibility treatment (drop-shadow) rather than the right-only
-// banner gradient. Only when a banner image is present, otherwise the header is
-// a solid color the theme text already reads against.
const hasBanner = computed(() => theme.value?.banner === true);
const dropdownOpen = ref(false);
@@ -90,23 +82,15 @@ const copyLanIp = async () => {
-
diff --git a/web/src/components/Header/ArrayUsage.vue b/web/src/components/Header/ArrayUsage.vue
index f52bff0d76..05602d1414 100644
--- a/web/src/components/Header/ArrayUsage.vue
+++ b/web/src/components/Header/ArrayUsage.vue
@@ -47,14 +47,18 @@ const hasData = computed(() => array.value != null);
-