diff --git a/src/@layouts/components/VerticalNavLayout.vue b/src/@layouts/components/VerticalNavLayout.vue index 6d327069..096eadc7 100644 --- a/src/@layouts/components/VerticalNavLayout.vue +++ b/src/@layouts/components/VerticalNavLayout.vue @@ -2,6 +2,7 @@ import { useDisplay } from 'vuetify' import VerticalNav from '@layouts/components/VerticalNav.vue' import GlassFixedShellBackplate from '@/components/theme/GlassFixedShellBackplate.vue' +import GlassNavbarRefractionDefs from '@/components/theme/GlassNavbarRefractionDefs.vue' import { readThemeCustomizerSettings, THEME_CUSTOMIZER_CHANGE_EVENT, @@ -11,6 +12,7 @@ import { useGlassFixedShellBackplate } from '@/composables/useGlassFixedShellBac import { usePWA } from '@/composables/usePWA' import { useShellScrollState } from '@/composables/useShellScrollState' import { useFooterDockHeight } from '@/composables/useFooterDockHeight' +import { supportsGlassNavbarLiveRefraction } from '@/utils/glassNavbarRefraction' const FLOATING_NAVBAR_INSET_PX = 16 @@ -26,6 +28,7 @@ export default defineComponent({ // App Dock 通过 Teleport 挂载到 body,不参与内容流;将实际高度交给布局用于末尾避让。 const { footerDockHeight } = useFooterDockHeight() const fixedShellBackplate = useGlassFixedShellBackplate() + const navbarRefractionMode = supportsGlassNavbarLiveRefraction() ? 'chromium' : 'goal1' const themeLayout = ref(readThemeCustomizerSettings().layout) const canUseDesktopLayout = computed(() => !mdAndDown.value && !appMode.value) const isOverlayShell = computed(() => mdAndDown.value && !appMode.value) @@ -208,6 +211,7 @@ export default defineComponent({ ? 'theme-qualified' : 'connected', 'data-shell-scroll-direction': shellScroll.direction.value, + 'data-glass-navbar-refraction': navbarRefractionMode, style: { '--layout-footer-dock-height': `${footerDockHeight.value ?? 0}px`, '--shell-floating-navbar-scale-x': floatingNavbarScale.value, @@ -215,6 +219,7 @@ export default defineComponent({ }, }, [ + navbarRefractionMode === 'chromium' ? h(GlassNavbarRefractionDefs) : null, fixedShellBackplateNode, verticalNav, h('div', { class: 'layout-content-wrapper' }, [navbar, main, footer]), diff --git a/src/@layouts/components/__tests__/VerticalNavLayout.spec.ts b/src/@layouts/components/__tests__/VerticalNavLayout.spec.ts index 57da91ea..d5a5517e 100644 --- a/src/@layouts/components/__tests__/VerticalNavLayout.spec.ts +++ b/src/@layouts/components/__tests__/VerticalNavLayout.spec.ts @@ -13,6 +13,7 @@ const mocks = vi.hoisted(() => ({ isStandaloneMode: false, isWindowControlsOverlayMode: false, mdAndDown: false, + navbarRefractionSupported: false, revision: undefined as { value: number } | undefined, state: 'expanded' as 'expanded' | 'compact' | 'revealed', })) @@ -85,6 +86,10 @@ vi.mock('@/composables/useGlassFixedShellBackplate', async () => { } }) +vi.mock('@/utils/glassNavbarRefraction', () => ({ + supportsGlassNavbarLiveRefraction: () => mocks.navbarRefractionSupported, +})) + vi.mock('@/composables/useThemeCustomizer', () => ({ readThemeCustomizerSettings: () => ({ layout: 'vertical' }), THEME_CUSTOMIZER_CHANGE_EVENT: 'moviepilot:theme-customizer-change', @@ -94,6 +99,10 @@ vi.mock('@/components/theme/GlassFixedShellBackplate.vue', () => ({ default: { template: '
' }, })) +vi.mock('@/components/theme/GlassNavbarRefractionDefs.vue', () => ({ + default: { template: '' }, +})) + vi.mock('@layouts/components/VerticalNav.vue', () => ({ default: { template: '' }, })) @@ -143,6 +152,7 @@ describe('VerticalNavLayout shell states', () => { mocks.isStandaloneMode = false mocks.isWindowControlsOverlayMode = false mocks.mdAndDown = false + mocks.navbarRefractionSupported = false mocks.state = 'expanded' }) @@ -177,6 +187,20 @@ describe('VerticalNavLayout shell states', () => { expect(revealedWrapper.get('.layout-navbar').attributes('data-shell-navbar-state')).toBe('revealed') }) + it('mounts live backdrop definitions only for the verified Chromium path', () => { + const goal1Wrapper = mountLayout() + + expect(goal1Wrapper.get('.layout-wrapper').attributes('data-glass-navbar-refraction')).toBe('goal1') + expect(goal1Wrapper.find('[data-testid="navbar-refraction-defs"]').exists()).toBe(false) + goal1Wrapper.unmount() + + mocks.navbarRefractionSupported = true + const chromiumWrapper = mountLayout() + + expect(chromiumWrapper.get('.layout-wrapper').attributes('data-glass-navbar-refraction')).toBe('chromium') + expect(chromiumWrapper.find('[data-testid="navbar-refraction-defs"]').exists()).toBe(true) + }) + it('keeps the footer contract stable across App and drawer shells', async () => { mocks.appMode = true mocks.mdAndDown = true diff --git a/src/components/theme/GlassNavbarRefractionDefs.vue b/src/components/theme/GlassNavbarRefractionDefs.vue new file mode 100644 index 00000000..db0342f8 --- /dev/null +++ b/src/components/theme/GlassNavbarRefractionDefs.vue @@ -0,0 +1,87 @@ + + + + + diff --git a/src/styles/__tests__/glassOverlayMaterial.spec.ts b/src/styles/__tests__/glassOverlayMaterial.spec.ts index d82491ed..63fcdc5c 100644 --- a/src/styles/__tests__/glassOverlayMaterial.spec.ts +++ b/src/styles/__tests__/glassOverlayMaterial.spec.ts @@ -392,4 +392,16 @@ describe('glass overlay material styles', () => { /\[data-glass-appearance='frosted'\]\[data-page-presentation-motion='active'\]\s+\.mp-page-route\s*\{[\s\S]*?opacity:\s*1;[\s\S]*?transform:\s*translate3d\(0,\s*var\(--mp-page-motion-translate-y,\s*0\),\s*0\);/, ) }) + + it('scopes live navbar refraction to Chromium clear and tinted floating shells', () => { + const styles = readFileSync(resolve(cwd(), 'src/styles/themes/glass.scss'), 'utf8') + + expect(styles).toContain("data-glass-navbar-refraction='chromium'") + expect(styles).toContain("url('#glass-navbar-live-refraction-balanced')") + expect(styles).toContain("url('#glass-navbar-live-refraction-high')") + expect(styles).toMatch( + /:is\(\[data-glass-appearance='clear'\], \[data-glass-appearance='tinted'\]\)[\s\S]*?\.layout-navbar-floating-eligible\.layout-navbar-away-from-top[\s\S]*?\.layout-navbar/, + ) + expect(styles).not.toContain("[data-glass-appearance='frosted'][data-glass-navbar-refraction='chromium']") + }) }) diff --git a/src/styles/themes/glass.scss b/src/styles/themes/glass.scss index ad79b8d4..2f8b8634 100644 --- a/src/styles/themes/glass.scss +++ b/src/styles/themes/glass.scss @@ -1651,3 +1651,58 @@ html[data-theme='glass'] body[data-theme='glass'] .native-login-field:focus-with transition: none; } } + +// Chromium 直接位移浏览器已合成的下层内容;其他引擎保留稳定的基础顶栏材质。 +html[data-theme='glass']:is([data-glass-appearance='clear'], [data-glass-appearance='tinted']) + body[data-theme='glass'] + .layout-wrapper[data-glass-navbar-refraction='chromium'].layout-navbar-floating-eligible.layout-navbar-away-from-top + .layout-navbar { + -webkit-backdrop-filter: none !important; + backdrop-filter: none !important; + background-color: transparent !important; + background-image: none !important; + + &::before { + position: absolute; + z-index: 0; + border: 1px solid rgba(255, 255, 255, 0.24); + border-radius: inherit; + -webkit-backdrop-filter: blur(1.5px) saturate(118%); + backdrop-filter: url('#glass-navbar-live-refraction-balanced') blur(1.5px) saturate(118%); + background: + linear-gradient(145deg, rgba(255, 255, 255, 0.13), transparent 34%), + linear-gradient(rgba(7, 14, 25, 0.06), rgba(7, 14, 25, 0.13)); + box-shadow: + inset 0 1px 0 rgba(255, 255, 255, 0.4), + inset 0 -1px 0 rgba(4, 10, 20, 0.22), + 0 12px 32px rgba(3, 7, 18, 0.16); + content: ''; + inset: 0; + pointer-events: none; + } + + .navbar-content-container { + position: relative; + z-index: 1; + } +} + +html[data-theme='glass'][data-glass-quality='high']:is( + [data-glass-appearance='clear'], + [data-glass-appearance='tinted'] + ) + body[data-theme='glass'] + .layout-wrapper[data-glass-navbar-refraction='chromium'].layout-navbar-floating-eligible.layout-navbar-away-from-top + .layout-navbar::before { + backdrop-filter: url('#glass-navbar-live-refraction-high') blur(1px) saturate(122%); +} + +html[data-theme='glass'][data-glass-appearance='tinted'] + body[data-theme='glass'] + .layout-wrapper[data-glass-navbar-refraction='chromium'].layout-navbar-floating-eligible.layout-navbar-away-from-top + .layout-navbar::before { + background: + linear-gradient(145deg, rgba(255, 255, 255, 0.14), transparent 36%), + linear-gradient(rgba(var(--glass-material-accent-rgb), 0.09), rgba(var(--glass-material-accent-rgb), 0.035)), + linear-gradient(rgba(7, 14, 25, 0.08), rgba(7, 14, 25, 0.15)); +} diff --git a/src/utils/__tests__/glassNavbarRefraction.spec.ts b/src/utils/__tests__/glassNavbarRefraction.spec.ts new file mode 100644 index 00000000..8dc5972b --- /dev/null +++ b/src/utils/__tests__/glassNavbarRefraction.spec.ts @@ -0,0 +1,31 @@ +import { describe, expect, it } from 'vitest' +import { supportsGlassNavbarLiveRefraction } from '@/utils/glassNavbarRefraction' + +describe('supportsGlassNavbarLiveRefraction', () => { + it('enables the verified Chromium engine path for Chrome and Edge', () => { + expect( + supportsGlassNavbarLiveRefraction({ + userAgent: 'Mozilla/5.0 Chrome/140.0.0.0 Safari/537.36', + userAgentData: { + brands: [{ brand: 'Chromium' }, { brand: 'Google Chrome' }], + }, + }), + ).toBe(true) + expect( + supportsGlassNavbarLiveRefraction({ + userAgent: 'Mozilla/5.0 Chrome/140.0.0.0 Safari/537.36 Edg/140.0.0.0', + userAgentData: { + brands: [{ brand: 'Chromium' }, { brand: 'Microsoft Edge' }], + }, + }), + ).toBe(true) + }) + + it.each([ + ['Safari', 'Mozilla/5.0 Version/26.4 Safari/605.1.15'], + ['iOS Chrome', 'Mozilla/5.0 CriOS/140.0.0.0 Mobile/15E148 Safari/604.1'], + ['Firefox', 'Mozilla/5.0 Firefox/142.0'], + ])('keeps %s on the stable Goal 1 material', (_browser, userAgent) => { + expect(supportsGlassNavbarLiveRefraction({ userAgent })).toBe(false) + }) +}) diff --git a/src/utils/glassNavbarRefraction.ts b/src/utils/glassNavbarRefraction.ts new file mode 100644 index 00000000..c198e888 --- /dev/null +++ b/src/utils/glassNavbarRefraction.ts @@ -0,0 +1,18 @@ +export interface GlassNavbarRefractionBrowserIdentity { + /** User-Agent Client Hints 暴露的浏览器品牌。 */ + userAgentData?: { + brands?: readonly { + brand: string + }[] + } + /** Client Hints 不可用时使用的传统浏览器标识。 */ + userAgent: string +} + +/** 仅在已验证 SVG backdrop 位移的 Chromium 引擎启用实时顶栏折射。 */ +export function supportsGlassNavbarLiveRefraction(browserIdentity: GlassNavbarRefractionBrowserIdentity = navigator) { + const brands = browserIdentity.userAgentData?.brands + if (brands?.length) return brands.some(({ brand }) => brand === 'Chromium') + + return /\b(?:Chrome|Chromium)\/\d+/u.test(browserIdentity.userAgent) +}