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
@@ -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)
})
})