From bf77c8ee04d05745792810e9e443fe457ac43662 Mon Sep 17 00:00:00 2001 From: InfinityPacer <160988576+InfinityPacer@users.noreply.github.com> Date: Thu, 30 Jul 2026 21:42:06 +0800 Subject: [PATCH] fix(glass): stabilize frosted fixed shells (#602) --- src/@layouts/components/VerticalNavLayout.vue | 20 +- src/App.vue | 30 +++ .../theme/GlassFixedShellBackplate.vue | 176 ++++++++++++++++++ .../GlassFixedShellBackplate.spec.ts | 90 +++++++++ .../useGlassFixedShellBackplate.spec.ts | 23 +++ .../useGlassFixedShellBackplate.ts | 57 ++++++ .../__tests__/glassOverlayMaterial.spec.ts | 12 +- src/styles/themes/glass.scss | 15 +- 8 files changed, 418 insertions(+), 5 deletions(-) create mode 100644 src/components/theme/GlassFixedShellBackplate.vue create mode 100644 src/components/theme/__tests__/GlassFixedShellBackplate.spec.ts create mode 100644 src/composables/__tests__/useGlassFixedShellBackplate.spec.ts create mode 100644 src/composables/useGlassFixedShellBackplate.ts diff --git a/src/@layouts/components/VerticalNavLayout.vue b/src/@layouts/components/VerticalNavLayout.vue index 4978a315..fbd59f81 100644 --- a/src/@layouts/components/VerticalNavLayout.vue +++ b/src/@layouts/components/VerticalNavLayout.vue @@ -1,11 +1,13 @@ + + + + diff --git a/src/components/theme/__tests__/GlassFixedShellBackplate.spec.ts b/src/components/theme/__tests__/GlassFixedShellBackplate.spec.ts new file mode 100644 index 00000000..07790189 --- /dev/null +++ b/src/components/theme/__tests__/GlassFixedShellBackplate.spec.ts @@ -0,0 +1,90 @@ +import { mount } from '@vue/test-utils' +import { describe, expect, it } from 'vitest' +import GlassFixedShellBackplate from '@/components/theme/GlassFixedShellBackplate.vue' +import type { GlassFixedShellBackplateLayer } from '@/composables/useGlassFixedShellBackplate' + +const initialLayers: readonly GlassFixedShellBackplateLayer[] = [ + { + key: 'front', + role: 'active', + style: { + 'backgroundImage': 'url(/wallpaper-current.jpg)', + '--glass-wallpaper-brightness': '0.82', + }, + url: '/wallpaper-current.jpg', + }, + { + key: 'back', + role: 'standby', + style: { + 'backgroundImage': 'url(/wallpaper-next.jpg)', + '--glass-wallpaper-brightness': '0.76', + }, + url: '/wallpaper-next.jpg', + }, +] + +describe('GlassFixedShellBackplate', () => { + it('renders the App-owned slots once for the shared desktop shell', () => { + const wrapper = mount(GlassFixedShellBackplate, { + props: { + isOverlayNav: false, + isOverlayNavActive: false, + layers: initialLayers, + transitionDurationMs: 1500, + }, + }) + + expect(wrapper.findAll('[data-backplate-surface="main"] [data-backplate-slot]')).toHaveLength(2) + expect(wrapper.find('[data-backplate-slot="front"]').classes()).toContain('is-active') + expect(wrapper.find('[data-backplate-slot="back"]').classes()).toContain('is-standby') + expect( + (wrapper.find('[data-backplate-slot="front"] > div').element as HTMLElement).style.backgroundImage, + ).toContain('/wallpaper-current.jpg') + expect(wrapper.find('[data-backplate-surface="main"]').attributes('style')).toContain( + '--glass-fixed-shell-transition-duration: 1500ms', + ) + expect(wrapper.find('[data-backplate-surface="overlay-nav"]').exists()).toBe(false) + }) + + it('preserves slot nodes while their active and previous roles swap', async () => { + const wrapper = mount(GlassFixedShellBackplate, { + props: { + isOverlayNav: false, + isOverlayNavActive: false, + layers: initialLayers, + transitionDurationMs: 1500, + }, + }) + const frontSlot = wrapper.find('[data-backplate-surface="main"] [data-backplate-slot="front"]').element + const backSlot = wrapper.find('[data-backplate-surface="main"] [data-backplate-slot="back"]').element + + await wrapper.setProps({ + layers: [ + { ...initialLayers[0], role: 'previous' }, + { ...initialLayers[1], role: 'active' }, + ], + }) + + expect(wrapper.find('[data-backplate-surface="main"] [data-backplate-slot="front"]').element).toBe(frontSlot) + expect(wrapper.find('[data-backplate-surface="main"] [data-backplate-slot="back"]').element).toBe(backSlot) + expect(wrapper.find('[data-backplate-slot="front"]').classes()).toContain('is-previous') + expect(wrapper.find('[data-backplate-slot="back"]').classes()).toContain('is-active') + }) + + it('adds a separately clipped surface only for mobile overlay navigation', () => { + const wrapper = mount(GlassFixedShellBackplate, { + props: { + isOverlayNav: true, + isOverlayNavActive: true, + layers: initialLayers, + transitionDurationMs: 1500, + }, + }) + + const overlay = wrapper.find('[data-backplate-surface="overlay-nav"]') + expect(overlay.exists()).toBe(true) + expect(overlay.classes()).toContain('is-visible') + expect(overlay.findAll('[data-backplate-slot]')).toHaveLength(2) + }) +}) diff --git a/src/composables/__tests__/useGlassFixedShellBackplate.spec.ts b/src/composables/__tests__/useGlassFixedShellBackplate.spec.ts new file mode 100644 index 00000000..5cec98e4 --- /dev/null +++ b/src/composables/__tests__/useGlassFixedShellBackplate.spec.ts @@ -0,0 +1,23 @@ +import { describe, expect, it } from 'vitest' +import { shouldUseGlassFixedShellBackplate } from '@/composables/useGlassFixedShellBackplate' + +describe('shouldUseGlassFixedShellBackplate', () => { + const eligible = { + appearance: 'frosted' as const, + hasWallpaper: true, + isAuthenticated: true, + quality: 'css' as const, + themeName: 'glass', + } + + it('enables the direct wallpaper backplate only for authenticated frosted CSS rendering', () => { + expect(shouldUseGlassFixedShellBackplate(eligible)).toBe(true) + expect(shouldUseGlassFixedShellBackplate({ ...eligible, appearance: 'clear' })).toBe(false) + expect(shouldUseGlassFixedShellBackplate({ ...eligible, appearance: 'tinted' })).toBe(false) + expect(shouldUseGlassFixedShellBackplate({ ...eligible, quality: 'balanced' })).toBe(false) + expect(shouldUseGlassFixedShellBackplate({ ...eligible, quality: 'high' })).toBe(false) + expect(shouldUseGlassFixedShellBackplate({ ...eligible, themeName: 'transparent' })).toBe(false) + expect(shouldUseGlassFixedShellBackplate({ ...eligible, isAuthenticated: false })).toBe(false) + expect(shouldUseGlassFixedShellBackplate({ ...eligible, hasWallpaper: false })).toBe(false) + }) +}) diff --git a/src/composables/useGlassFixedShellBackplate.ts b/src/composables/useGlassFixedShellBackplate.ts new file mode 100644 index 00000000..76b34946 --- /dev/null +++ b/src/composables/useGlassFixedShellBackplate.ts @@ -0,0 +1,57 @@ +import { inject, provide, shallowRef, type InjectionKey, type Ref, type StyleValue } from 'vue' +import type { ThemeCustomizerGlassAppearance, ThemeCustomizerGlassQuality } from '@/composables/useThemeCustomizer' +import type { LoginBackgroundLayer } from '@/utils/loginPresentation' + +export interface GlassFixedShellBackplateLayer extends LoginBackgroundLayer { + /** 当前槽位直接采样壁纸所需的背景图与色调变量。 */ + style: StyleValue +} + +export interface GlassFixedShellBackplateContext { + /** App 持有的稳定双槽位;布局只消费,不建立第二套壁纸生命周期。 */ + layers: Readonly> + /** 双槽位角色交换时使用的统一交叉淡化时长。 */ + transitionDurationMs: number +} + +interface GlassFixedShellBackplateEligibility { + /** 当前玻璃材质。 */ + appearance: ThemeCustomizerGlassAppearance + /** 稳定双槽位中是否已经存在可显示的壁纸。 */ + hasWallpaper: boolean + /** 当前页面是否处于认证后的主布局。 */ + isAuthenticated: boolean + /** 当前玻璃渲染质量。 */ + quality: ThemeCustomizerGlassQuality + /** Vuetify 当前解析后的主题名。 */ + themeName: string +} + +const GLASS_FIXED_SHELL_BACKPLATE_KEY: InjectionKey = + Symbol('glass-fixed-shell-backplate') +const EMPTY_FIXED_SHELL_LAYERS = shallowRef([]) +const EMPTY_FIXED_SHELL_CONTEXT: GlassFixedShellBackplateContext = { + layers: EMPTY_FIXED_SHELL_LAYERS, + transitionDurationMs: 0, +} + +/** 只有磨砂标准使用直接壁纸背板;其他材质和 GPU 质量继续走既有呈现路径。 */ +export function shouldUseGlassFixedShellBackplate(options: GlassFixedShellBackplateEligibility) { + return ( + options.isAuthenticated && + options.themeName === 'glass' && + options.appearance === 'frosted' && + options.quality === 'css' && + options.hasWallpaper + ) +} + +/** 从 App 向认证后布局提供唯一的 fixed-shell 壁纸双槽位。 */ +export function provideGlassFixedShellBackplate(context: GlassFixedShellBackplateContext) { + provide(GLASS_FIXED_SHELL_BACKPLATE_KEY, context) +} + +/** 读取 fixed-shell 双槽位;不在 App 树下时返回稳定空上下文。 */ +export function useGlassFixedShellBackplate() { + return inject(GLASS_FIXED_SHELL_BACKPLATE_KEY, EMPTY_FIXED_SHELL_CONTEXT) +} diff --git a/src/styles/__tests__/glassOverlayMaterial.spec.ts b/src/styles/__tests__/glassOverlayMaterial.spec.ts index 84406791..9ae7a39a 100644 --- a/src/styles/__tests__/glassOverlayMaterial.spec.ts +++ b/src/styles/__tests__/glassOverlayMaterial.spec.ts @@ -33,13 +33,19 @@ describe('glass overlay material styles', () => { ) }) - it('keeps the fixed navigation backdrop isolated from route content and its scrollbar', () => { + it('keeps frosted CSS fixed shells on the stable wallpaper backplate', () => { const styles = readFileSync(resolve(cwd(), 'src/styles/themes/glass.scss'), 'utf8') + const backplate = readFileSync(resolve(cwd(), 'src/components/theme/GlassFixedShellBackplate.vue'), 'utf8') - expect(styles).toContain('--glass-fixed-shell-backdrop-filter: blur(min(var(--glass-blur-raised), 60px))') + expect(styles).toContain('--glass-fixed-shell-backplate-filter: blur(min(var(--glass-blur-raised), 60px))') expect(styles).toMatch( - /\.layout-vertical-nav\s*\{[\s\S]*?isolation:\s*isolate;[\s\S]*?backdrop-filter:\s*none;[\s\S]*?&::before\s*\{[\s\S]*?backdrop-filter:\s*var\(--glass-fixed-shell-backdrop-filter\);/, + /&\[data-glass-appearance='frosted'\]\[data-glass-quality='css'\][\s\S]*?\.layout-vertical-nav::before,[\s\S]*?\.layout-navbar,[\s\S]*?backdrop-filter:\s*none\s*!important;/, ) + expect(backplate).toContain('filter: var(--glass-fixed-shell-backplate-filter)') + expect(backplate).not.toContain('backdrop-filter') + expect(backplate).toContain('var(--glass-fixed-shell-nav-inline-size) 100%') + expect(backplate).toContain('.layout-wrapper:is(.layout-horizontal-nav-active, .layout-overlay-nav)') + expect(backplate).toContain('.glass-fixed-shell-backplate--overlay-nav') expect(styles).toMatch(/\.layout-vertical-nav \.ps__rail-y\s*\{[\s\S]*?inset-inline-end:\s*0\.5rem !important;/) }) diff --git a/src/styles/themes/glass.scss b/src/styles/themes/glass.scss index 36403b8a..713e713f 100644 --- a/src/styles/themes/glass.scss +++ b/src/styles/themes/glass.scss @@ -46,6 +46,7 @@ html[data-theme='glass'] { --glass-surface-backdrop-filter: var(--glass-native-surface-backdrop-filter); --glass-raised-backdrop-filter: brightness(var(--glass-transmission-brightness)); --glass-fixed-shell-backdrop-filter: var(--glass-raised-backdrop-filter); + --glass-fixed-shell-backplate-filter: brightness(var(--glass-transmission-brightness)); --glass-control-backdrop-filter: none; --glass-control-prominent-backdrop-filter: none; --glass-navbar-backdrop-filter: var(--glass-raised-backdrop-filter); @@ -210,6 +211,8 @@ html[data-theme='glass'] { // 固定全高导航限制采样半径,避免页面重绘扩大其 backdrop 栅格化区域。 --glass-fixed-shell-backdrop-filter: blur(min(var(--glass-blur-raised), 60px)) saturate(var(--glass-saturate)) brightness(var(--glass-transmission-brightness)); + --glass-fixed-shell-backplate-filter: blur(min(var(--glass-blur-raised), 60px)) saturate(var(--glass-saturate)) + brightness(var(--glass-transmission-brightness)); --glass-control-prominent-backdrop-filter: blur(24px) saturate(150%); --glass-overlay-surface: rgba(var(--v-theme-background), calc(0.24 + var(--glass-surface-density, 0.86) * 0.12)); --glass-overlay-blur: min(var(--glass-blur-raised), 36px); @@ -443,7 +446,7 @@ html[data-theme='glass'] { background: transparent !important; box-shadow: none !important; - // 导航材质与可变菜单内容分层,路由激活态切换不应重栅格化整块全高背板。 + // 导航色层与可变菜单内容分离,路由激活态切换不应重建整块材质。 &::before { position: absolute; z-index: -1; @@ -459,6 +462,16 @@ html[data-theme='glass'] { } } + // 磨砂标准直接渲染稳定壁纸背板,固定功能层不得读取随页面重绘的 document backdrop。 + &[data-glass-appearance='frosted'][data-glass-quality='css'] body[data-theme='glass'] { + .layout-vertical-nav::before, + .layout-navbar, + .layout-wrapper.layout-horizontal-nav-active.layout-horizontal-nav-scrolled.layout-navbar-fixed .layout-navbar { + -webkit-backdrop-filter: none !important; + backdrop-filter: none !important; + } + } + // 滚动条与导航材质边界保持间距,避免窄轨道参与全高背板的合成。 .layout-vertical-nav .ps__rail-y { inset-inline-end: 0.5rem !important;