diff --git a/web/postcss/scopeTailwindToUnapi.ts b/web/postcss/scopeTailwindToUnapi.ts index bdc36008a8..f093308365 100644 --- a/web/postcss/scopeTailwindToUnapi.ts +++ b/web/postcss/scopeTailwindToUnapi.ts @@ -52,6 +52,7 @@ const KEYFRAME_AT_RULES = new Set(['keyframes']); const NON_SCOPED_AT_RULES = new Set(['font-face', 'page']); const MERGE_WITH_SCOPE_PATTERNS: RegExp[] = [/^\.has-custom-/, /^\.dark\b/]; const UNSCOPED_PATTERNS: RegExp[] = [/^\.Theme--/]; +const WEBGUI_BANNER_SELECTOR = /^(?:\.has-banner-gradient\s+)?#header\.image(?=$|[:\s>+~])/; function shouldScopeRule(rule: Rule, targetLayers: Set, includeRootRules: boolean): boolean { const hasSelectorString = typeof rule.selector === 'string' && rule.selector.length > 0; @@ -120,6 +121,11 @@ function prefixSelector(selector: string, scope: string): string { return trimmed; } + // The native WebGUI header contains .unapi components, rather than living inside their scope. + if (WEBGUI_BANNER_SELECTOR.test(trimmed)) { + return trimmed; + } + // Do not scope Theme-- classes - they should remain global const firstToken = trimmed.split(/[\s>+~]/, 1)[0] ?? ''; if (!firstToken.includes('\\:') && UNSCOPED_PATTERNS.some((pattern) => pattern.test(firstToken))) { diff --git a/web/src/__tests__/scopeTailwindToUnapi.spec.ts b/web/src/__tests__/scopeTailwindToUnapi.spec.ts index b7d16a398b..cc38c612df 100644 --- a/web/src/__tests__/scopeTailwindToUnapi.spec.ts +++ b/web/src/__tests__/scopeTailwindToUnapi.spec.ts @@ -67,6 +67,59 @@ describe('scopeTailwindToUnapi plugin', () => { expect(rule.selector).toBe('.unapi .card'); }); + it('applies banner styles to the native WebGUI header outside the app scope', () => { + const plugin = scopeTailwindToUnapi(); + const container = document.createElement('div'); + container.innerHTML = + ''; + document.body.append(container); + document.documentElement.classList.add('has-banner-gradient'); + const rule = createRule([ + '#header.image:not(.unraid-consolidated-header)', + '.has-banner-gradient #header.image:not(.unraid-consolidated-header)', + '.has-banner-gradient #header.image:not(.unraid-consolidated-header)::before', + '.has-banner-gradient #header.image:not(.unraid-consolidated-header):before', + '.has-banner-gradient #header.image:not(.unraid-consolidated-header) > *', + ]); + + try { + plugin.Rule?.(rule); + + for (const selector of rule.selectors ?? []) { + expect(document.querySelector(selector.replace(/::?before$/, ''))).not.toBeNull(); + } + + const header = container.querySelector('#header'); + header?.classList.add('unraid-consolidated-header'); + header?.replaceChildren(document.createElement('unraid-header')); + for (const selector of rule.selectors ?? []) { + expect(document.querySelector(selector.replace(/::?before$/, ''))).toBeNull(); + } + header?.classList.remove('unraid-consolidated-header'); + header?.replaceChildren(document.createElement('unraid-user-profile')); + + document.documentElement.classList.remove('has-banner-gradient'); + for (const selector of rule.selectors?.slice(1) ?? []) { + expect(document.querySelector(selector.replace(/::?before$/, ''))).toBeNull(); + } + } finally { + container.remove(); + document.documentElement.classList.remove('has-banner-gradient'); + } + }); + + it('still scopes unrelated header and banner selectors', () => { + const plugin = scopeTailwindToUnapi(); + const rule = createRule(['#header.image-preview', '.has-banner-gradient .card']); + + plugin.Rule?.(rule); + + expect(rule.selectors).toEqual([ + '.unapi #header.image-preview', + '.unapi .has-banner-gradient .card', + ]); + }); + it('processes large rule sets within the target budget', () => { const plugin = scopeTailwindToUnapi(); const totalRules = 10_000; diff --git a/web/src/assets/main.css b/web/src/assets/main.css index aeddbcde69..b79144576c 100644 --- a/web/src/assets/main.css +++ b/web/src/assets/main.css @@ -7,9 +7,9 @@ @layer theme, unapi-base, components, utilities; /* Import theme and utilities only - no global preflight */ -@import "tailwindcss/theme.css" layer(theme); -@import "tailwindcss/utilities.css" layer(utilities); -@import "@nuxt/ui"; +@import 'tailwindcss/theme.css' layer(theme); +@import 'tailwindcss/utilities.css' layer(utilities); +@import '@nuxt/ui'; @import 'tw-animate-css'; @import '../../../@tailwind-shared/index.css'; @@ -25,7 +25,7 @@ @layer unapi-base { /* Scope all Tailwind preflight styles to .unapi */ .unapi { - @import "tailwindcss/preflight.css"; + @import 'tailwindcss/preflight.css'; } /* Disable mobile-browser font-boosting (text auto-inflation) for the Vue @@ -95,32 +95,37 @@ iframe#progressFrame { } /* Header banner compatibility tweaks */ -#header.image { +#header.image:not(.unraid-consolidated-header) { background-position: center center; background-repeat: no-repeat; background-size: cover; } -.has-banner-gradient #header.image { - position: relative; - overflow: hidden; +.has-banner-gradient #header.image:not(.unraid-consolidated-header) { + --webgui-banner-color: var( + --header-gradient-end, + var(--color-header-gradient-end, var(--header-background-color)) + ); + isolation: isolate; } -.has-banner-gradient #header.image::before { +.has-banner-gradient #header.image:not(.unraid-consolidated-header)::before { content: ''; position: absolute; inset: 0; pointer-events: none; - background-repeat: no-repeat; - background-position: left center, right center; - background-size: min(30%, 320px) 100%, min(30%, 320px) 100%; - background-image: - var(--banner-gradient), - linear-gradient(270deg, var(--header-gradient-end, var(--color-header-gradient-end, rgba(0, 0, 0, 1))) 0%, var(--header-gradient-end, var(--color-header-gradient-end, rgba(0, 0, 0, 1))) 10%, color-mix(in srgb, var(--header-gradient-end, var(--color-header-gradient-end, rgba(0, 0, 0, 1))) 90%, transparent) 25%, color-mix(in srgb, var(--header-gradient-end, var(--color-header-gradient-end, rgba(0, 0, 0, 1))) 60%, transparent) 40%, color-mix(in srgb, var(--header-gradient-end, var(--color-header-gradient-end, rgba(0, 0, 0, 1))) 30%, transparent) 55%, var(--header-gradient-start, var(--color-header-gradient-start, rgba(0, 0, 0, 0))) 70%, var(--header-gradient-start, var(--color-header-gradient-start, rgba(0, 0, 0, 0))) var(--banner-gradient-stop, 30%)); - z-index: 0; + background-image: linear-gradient( + 270deg, + var(--webgui-banner-color) 0, + var(--webgui-banner-color) 20rem, + color-mix(in srgb, var(--webgui-banner-color) 50%, transparent) 30rem, + transparent 40rem + ); + z-index: -1; } -.has-banner-gradient #header.image > * { - position: relative; - z-index: 1; +@media (max-width: 768px) { + .has-banner-gradient #header.image:not(.unraid-consolidated-header)::before { + background-image: linear-gradient(90deg, transparent 40%, var(--webgui-banner-color) 80%); + } }