mirror of
https://github.com/jxxghp/MoviePilot-Frontend.git
synced 2026-09-05 07:28:37 +08:00
fix(glass): stabilize frosted fixed shells (#602)
This commit is contained in:
@@ -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
@@ -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;/)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user