fix(glass): stabilize frosted fixed shells (#602)

This commit is contained in:
InfinityPacer
2026-07-30 21:42:06 +08:00
committed by GitHub
parent 6d5550f198
commit bf77c8ee04
8 changed files with 418 additions and 5 deletions
+19 -1
View File
@@ -1,11 +1,13 @@
<script lang="ts"> <script lang="ts">
import { useDisplay } from 'vuetify' import { useDisplay } from 'vuetify'
import VerticalNav from '@layouts/components/VerticalNav.vue' import VerticalNav from '@layouts/components/VerticalNav.vue'
import GlassFixedShellBackplate from '@/components/theme/GlassFixedShellBackplate.vue'
import { import {
readThemeCustomizerSettings, readThemeCustomizerSettings,
THEME_CUSTOMIZER_CHANGE_EVENT, THEME_CUSTOMIZER_CHANGE_EVENT,
type ThemeCustomizerSettings, type ThemeCustomizerSettings,
} from '@/composables/useThemeCustomizer' } from '@/composables/useThemeCustomizer'
import { useGlassFixedShellBackplate } from '@/composables/useGlassFixedShellBackplate'
import { usePWA } from '@/composables/usePWA' import { usePWA } from '@/composables/usePWA'
export default defineComponent({ export default defineComponent({
@@ -17,6 +19,7 @@ export default defineComponent({
const route = useRoute() const route = useRoute()
const { mdAndDown } = useDisplay() const { mdAndDown } = useDisplay()
const { appMode } = usePWA() const { appMode } = usePWA()
const fixedShellBackplate = useGlassFixedShellBackplate()
const themeLayout = ref(readThemeCustomizerSettings().layout) const themeLayout = ref(readThemeCustomizerSettings().layout)
const canUseDesktopLayout = computed(() => !mdAndDown.value && !appMode.value) const canUseDesktopLayout = computed(() => !mdAndDown.value && !appMode.value)
const isCollapsedLayout = computed(() => canUseDesktopLayout.value && themeLayout.value === 'collapsed') const isCollapsedLayout = computed(() => canUseDesktopLayout.value && themeLayout.value === 'collapsed')
@@ -85,6 +88,16 @@ export default defineComponent({
}, },
) )
const fixedShellBackplateNode =
fixedShellBackplate.layers.value.length > 0
? h(GlassFixedShellBackplate, {
isOverlayNav: mdAndDown.value,
isOverlayNavActive: isOverlayNavActive.value,
layers: fixedShellBackplate.layers.value,
transitionDurationMs: fixedShellBackplate.transitionDurationMs,
})
: null
// 👉 Navbar // 👉 Navbar
const navbar = h( const navbar = h(
'header', 'header',
@@ -149,7 +162,12 @@ export default defineComponent({
!isHorizontalLayout.value && isNavbarScrolled && 'window-scrolled', !isHorizontalLayout.value && isNavbarScrolled && 'window-scrolled',
], ],
}, },
[verticalNav, h('div', { class: 'layout-content-wrapper' }, [navbar, main, footer]), layoutOverlay], [
fixedShellBackplateNode,
verticalNav,
h('div', { class: 'layout-content-wrapper' }, [navbar, main, footer]),
layoutOverlay,
],
) )
} }
}, },
+30
View File
@@ -28,6 +28,11 @@ import { configureApexChartsTheme } from '@/utils/apexCharts'
import { useGlobalOfflineStatus, type ConnectionFailureReason } from '@/composables/useOfflineStatus' import { useGlobalOfflineStatus, type ConnectionFailureReason } from '@/composables/useOfflineStatus'
import { useAppActivityLifecycle } from '@/composables/useAppActivityLifecycle' import { useAppActivityLifecycle } from '@/composables/useAppActivityLifecycle'
import { useGlassWallpaperTransaction } from '@/composables/useGlassWallpaperTransaction' import { useGlassWallpaperTransaction } from '@/composables/useGlassWallpaperTransaction'
import {
provideGlassFixedShellBackplate,
shouldUseGlassFixedShellBackplate,
type GlassFixedShellBackplateLayer,
} from '@/composables/useGlassFixedShellBackplate'
import { import {
BACKGROUND_ROTATION_GRACE_MS, BACKGROUND_ROTATION_GRACE_MS,
createBackgroundCandidateOrderResolver, createBackgroundCandidateOrderResolver,
@@ -303,6 +308,31 @@ function getBackgroundLayerStyle(layer: LoginBackgroundLayer) {
} }
} }
const fixedShellBackplateLayers = computed<readonly GlassFixedShellBackplateLayer[]>(() => {
const hasWallpaper = renderedBackgroundLayers.value.some(layer => Boolean(layer.url))
if (
!shouldUseGlassFixedShellBackplate({
appearance: effectiveGlassSettings.value.glassAppearance,
hasWallpaper,
isAuthenticated: Boolean(isLogin.value),
quality: effectiveGlassSettings.value.glassQuality,
themeName: globalTheme.name.value,
})
) {
return []
}
return renderedBackgroundLayers.value.map(layer => ({
...layer,
style: getBackgroundLayerStyle(layer),
}))
})
provideGlassFixedShellBackplate({
layers: fixedShellBackplateLayers,
transitionDurationMs: BACKGROUND_CROSSFADE_DURATION_MS,
})
applyTransparentBackgroundSettings() applyTransparentBackgroundSettings()
void router.isReady().then(() => { void router.isReady().then(() => {
@@ -0,0 +1,176 @@
<script lang="ts" setup>
import type { GlassFixedShellBackplateLayer } from '@/composables/useGlassFixedShellBackplate'
interface Props {
/** 移动端 overlay 导航当前是否可见。 */
isOverlayNavActive: boolean
/** 当前布局是否使用移动端 overlay 导航。 */
isOverlayNav: boolean
/** App 壁纸状态机提供的稳定双槽位。 */
layers: readonly GlassFixedShellBackplateLayer[]
/** 与全局壁纸事务一致的交叉淡化时长。 */
transitionDurationMs: number
}
const props = defineProps<Props>()
const transitionStyle = computed(() => ({
'--glass-fixed-shell-transition-duration': `${Math.max(0, props.transitionDurationMs)}ms`,
}))
</script>
<template>
<div
class="glass-fixed-shell-backplate glass-fixed-shell-backplate--main"
data-backplate-surface="main"
:style="transitionStyle"
aria-hidden="true"
>
<div
v-for="layer in layers"
:key="layer.key"
class="glass-fixed-shell-backplate__layer"
:class="`is-${layer.role}`"
:data-backplate-slot="layer.key"
>
<div class="glass-fixed-shell-backplate__wallpaper" :style="layer.style" />
</div>
</div>
<div
v-if="isOverlayNav"
class="glass-fixed-shell-backplate glass-fixed-shell-backplate--overlay-nav"
:class="{ 'is-visible': isOverlayNavActive }"
data-backplate-surface="overlay-nav"
:style="transitionStyle"
aria-hidden="true"
>
<div
v-for="layer in layers"
:key="layer.key"
class="glass-fixed-shell-backplate__layer"
:class="`is-${layer.role}`"
:data-backplate-slot="layer.key"
>
<div class="glass-fixed-shell-backplate__wallpaper" :style="layer.style" />
</div>
</div>
</template>
<style lang="scss">
@use '@configured-variables' as variables;
@use '@layouts/styles/mixins';
.glass-fixed-shell-backplate {
position: fixed;
overflow: hidden;
contain: strict;
inset: 0;
isolation: isolate;
pointer-events: none;
}
.glass-fixed-shell-backplate--main {
--glass-fixed-shell-nav-inline-size: #{variables.$layout-vertical-nav-width};
z-index: variables.$layout-vertical-nav-layout-navbar-z-index - 1;
clip-path: polygon(
0 0,
calc(100% - 0.5rem) 0,
calc(100% - 0.5rem) var(--layout-navbar-block-size),
var(--glass-fixed-shell-nav-inline-size) var(--layout-navbar-block-size),
var(--glass-fixed-shell-nav-inline-size) 100%,
0 100%
);
transition: clip-path 0.25s ease-in-out;
}
.layout-wrapper.layout-vertical-nav-collapsed > .glass-fixed-shell-backplate--main {
--glass-fixed-shell-nav-inline-size: #{variables.$layout-vertical-nav-collapsed-width};
}
.layout-wrapper:is(.layout-horizontal-nav-active, .layout-overlay-nav) > .glass-fixed-shell-backplate--main {
clip-path: inset(0 0 calc(100% - var(--layout-navbar-block-size)) 0);
}
[dir='rtl'] .layout-wrapper > .glass-fixed-shell-backplate--main {
clip-path: polygon(
0.5rem 0,
100% 0,
100% 100%,
calc(100% - var(--glass-fixed-shell-nav-inline-size)) 100%,
calc(100% - var(--glass-fixed-shell-nav-inline-size)) var(--layout-navbar-block-size),
0.5rem var(--layout-navbar-block-size)
);
}
[dir='rtl']
.layout-wrapper:is(.layout-horizontal-nav-active, .layout-overlay-nav)
> .glass-fixed-shell-backplate--main {
clip-path: inset(0 0 calc(100% - var(--layout-navbar-block-size)) 0);
}
.glass-fixed-shell-backplate--overlay-nav {
z-index: variables.$layout-vertical-nav-z-index - 1;
clip-path: inset(0 100% 0 0);
transition: clip-path 0.25s ease-in-out;
&.is-visible {
clip-path: inset(0 calc(100% - #{variables.$layout-vertical-nav-width}) 0 0);
}
@include mixins.rtl {
clip-path: inset(0 0 0 100%);
&.is-visible {
clip-path: inset(0 0 0 calc(100% - #{variables.$layout-vertical-nav-width}));
}
}
}
.glass-fixed-shell-backplate__layer {
position: absolute;
filter: var(--glass-fixed-shell-backplate-filter);
inset: 0;
opacity: 0;
transition: opacity var(--glass-fixed-shell-transition-duration, 1500ms) ease;
will-change: opacity;
&.is-active {
z-index: 2;
opacity: 0.92;
}
&.is-previous {
z-index: 1;
}
}
.glass-fixed-shell-backplate__wallpaper {
position: absolute;
background-position: center;
background-repeat: no-repeat;
background-size: cover;
filter: brightness(var(--glass-wallpaper-brightness, 0.82)) saturate(0.9);
inset: 0;
&::after {
position: absolute;
background: linear-gradient(rgba(6, 10, 19, 24%) 0%, rgba(6, 10, 19, 48%) 100%), rgba(11, 19, 34, 8%);
content: '';
inset: 0;
}
}
@media (prefers-reduced-motion: reduce) {
.glass-fixed-shell-backplate,
.glass-fixed-shell-backplate__layer {
transition-duration: 0.01ms !important;
}
}
@media (prefers-reduced-transparency: reduce) {
.glass-fixed-shell-backplate {
display: none;
}
}
</style>
@@ -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)
})
})
@@ -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)
})
})
@@ -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<Ref<readonly GlassFixedShellBackplateLayer[]>>
/** 双槽位角色交换时使用的统一交叉淡化时长。 */
transitionDurationMs: number
}
interface GlassFixedShellBackplateEligibility {
/** 当前玻璃材质。 */
appearance: ThemeCustomizerGlassAppearance
/** 稳定双槽位中是否已经存在可显示的壁纸。 */
hasWallpaper: boolean
/** 当前页面是否处于认证后的主布局。 */
isAuthenticated: boolean
/** 当前玻璃渲染质量。 */
quality: ThemeCustomizerGlassQuality
/** Vuetify 当前解析后的主题名。 */
themeName: string
}
const GLASS_FIXED_SHELL_BACKPLATE_KEY: InjectionKey<GlassFixedShellBackplateContext> =
Symbol('glass-fixed-shell-backplate')
const EMPTY_FIXED_SHELL_LAYERS = shallowRef<readonly GlassFixedShellBackplateLayer[]>([])
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)
}
@@ -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 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( 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;/) expect(styles).toMatch(/\.layout-vertical-nav \.ps__rail-y\s*\{[\s\S]*?inset-inline-end:\s*0\.5rem !important;/)
}) })
+14 -1
View File
@@ -46,6 +46,7 @@ html[data-theme='glass'] {
--glass-surface-backdrop-filter: var(--glass-native-surface-backdrop-filter); --glass-surface-backdrop-filter: var(--glass-native-surface-backdrop-filter);
--glass-raised-backdrop-filter: brightness(var(--glass-transmission-brightness)); --glass-raised-backdrop-filter: brightness(var(--glass-transmission-brightness));
--glass-fixed-shell-backdrop-filter: var(--glass-raised-backdrop-filter); --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-backdrop-filter: none;
--glass-control-prominent-backdrop-filter: none; --glass-control-prominent-backdrop-filter: none;
--glass-navbar-backdrop-filter: var(--glass-raised-backdrop-filter); --glass-navbar-backdrop-filter: var(--glass-raised-backdrop-filter);
@@ -210,6 +211,8 @@ html[data-theme='glass'] {
// 固定全高导航限制采样半径,避免页面重绘扩大其 backdrop 栅格化区域。 // 固定全高导航限制采样半径,避免页面重绘扩大其 backdrop 栅格化区域。
--glass-fixed-shell-backdrop-filter: blur(min(var(--glass-blur-raised), 60px)) saturate(var(--glass-saturate)) --glass-fixed-shell-backdrop-filter: blur(min(var(--glass-blur-raised), 60px)) saturate(var(--glass-saturate))
brightness(var(--glass-transmission-brightness)); 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-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-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); --glass-overlay-blur: min(var(--glass-blur-raised), 36px);
@@ -443,7 +446,7 @@ html[data-theme='glass'] {
background: transparent !important; background: transparent !important;
box-shadow: none !important; box-shadow: none !important;
// 导航材质与可变菜单内容分,路由激活态切换不应重栅格化整块全高背板 // 导航色层与可变菜单内容分,路由激活态切换不应重建整块材质
&::before { &::before {
position: absolute; position: absolute;
z-index: -1; 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 { .layout-vertical-nav .ps__rail-y {
inset-inline-end: 0.5rem !important; inset-inline-end: 0.5rem !important;