feat(theme): prototype live navbar refraction

This commit is contained in:
InfinityPacer
2026-09-04 11:25:08 +08:00
parent 4309094f63
commit 91859cf97f
7 changed files with 232 additions and 0 deletions
@@ -2,6 +2,7 @@
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 GlassFixedShellBackplate from '@/components/theme/GlassFixedShellBackplate.vue'
import GlassNavbarRefractionDefs from '@/components/theme/GlassNavbarRefractionDefs.vue'
import { import {
readThemeCustomizerSettings, readThemeCustomizerSettings,
THEME_CUSTOMIZER_CHANGE_EVENT, THEME_CUSTOMIZER_CHANGE_EVENT,
@@ -11,6 +12,7 @@ import { useGlassFixedShellBackplate } from '@/composables/useGlassFixedShellBac
import { usePWA } from '@/composables/usePWA' import { usePWA } from '@/composables/usePWA'
import { useShellScrollState } from '@/composables/useShellScrollState' import { useShellScrollState } from '@/composables/useShellScrollState'
import { useFooterDockHeight } from '@/composables/useFooterDockHeight' import { useFooterDockHeight } from '@/composables/useFooterDockHeight'
import { supportsGlassNavbarLiveRefraction } from '@/utils/glassNavbarRefraction'
const FLOATING_NAVBAR_INSET_PX = 16 const FLOATING_NAVBAR_INSET_PX = 16
@@ -26,6 +28,7 @@ export default defineComponent({
// App Dock 通过 Teleport 挂载到 body,不参与内容流;将实际高度交给布局用于末尾避让。 // App Dock 通过 Teleport 挂载到 body,不参与内容流;将实际高度交给布局用于末尾避让。
const { footerDockHeight } = useFooterDockHeight() const { footerDockHeight } = useFooterDockHeight()
const fixedShellBackplate = useGlassFixedShellBackplate() const fixedShellBackplate = useGlassFixedShellBackplate()
const navbarRefractionMode = supportsGlassNavbarLiveRefraction() ? 'chromium' : 'goal1'
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 isOverlayShell = computed(() => mdAndDown.value && !appMode.value) const isOverlayShell = computed(() => mdAndDown.value && !appMode.value)
@@ -208,6 +211,7 @@ export default defineComponent({
? 'theme-qualified' ? 'theme-qualified'
: 'connected', : 'connected',
'data-shell-scroll-direction': shellScroll.direction.value, 'data-shell-scroll-direction': shellScroll.direction.value,
'data-glass-navbar-refraction': navbarRefractionMode,
style: { style: {
'--layout-footer-dock-height': `${footerDockHeight.value ?? 0}px`, '--layout-footer-dock-height': `${footerDockHeight.value ?? 0}px`,
'--shell-floating-navbar-scale-x': floatingNavbarScale.value, '--shell-floating-navbar-scale-x': floatingNavbarScale.value,
@@ -215,6 +219,7 @@ export default defineComponent({
}, },
}, },
[ [
navbarRefractionMode === 'chromium' ? h(GlassNavbarRefractionDefs) : null,
fixedShellBackplateNode, fixedShellBackplateNode,
verticalNav, verticalNav,
h('div', { class: 'layout-content-wrapper' }, [navbar, main, footer]), h('div', { class: 'layout-content-wrapper' }, [navbar, main, footer]),
@@ -13,6 +13,7 @@ const mocks = vi.hoisted(() => ({
isStandaloneMode: false, isStandaloneMode: false,
isWindowControlsOverlayMode: false, isWindowControlsOverlayMode: false,
mdAndDown: false, mdAndDown: false,
navbarRefractionSupported: false,
revision: undefined as { value: number } | undefined, revision: undefined as { value: number } | undefined,
state: 'expanded' as 'expanded' | 'compact' | 'revealed', 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', () => ({ vi.mock('@/composables/useThemeCustomizer', () => ({
readThemeCustomizerSettings: () => ({ layout: 'vertical' }), readThemeCustomizerSettings: () => ({ layout: 'vertical' }),
THEME_CUSTOMIZER_CHANGE_EVENT: 'moviepilot:theme-customizer-change', THEME_CUSTOMIZER_CHANGE_EVENT: 'moviepilot:theme-customizer-change',
@@ -94,6 +99,10 @@ vi.mock('@/components/theme/GlassFixedShellBackplate.vue', () => ({
default: { template: '<div data-testid="fixed-shell-backplate" />' }, default: { template: '<div data-testid="fixed-shell-backplate" />' },
})) }))
vi.mock('@/components/theme/GlassNavbarRefractionDefs.vue', () => ({
default: { template: '<svg data-testid="navbar-refraction-defs" />' },
}))
vi.mock('@layouts/components/VerticalNav.vue', () => ({ vi.mock('@layouts/components/VerticalNav.vue', () => ({
default: { template: '<aside data-testid="vertical-nav"><slot /></aside>' }, default: { template: '<aside data-testid="vertical-nav"><slot /></aside>' },
})) }))
@@ -143,6 +152,7 @@ describe('VerticalNavLayout shell states', () => {
mocks.isStandaloneMode = false mocks.isStandaloneMode = false
mocks.isWindowControlsOverlayMode = false mocks.isWindowControlsOverlayMode = false
mocks.mdAndDown = false mocks.mdAndDown = false
mocks.navbarRefractionSupported = false
mocks.state = 'expanded' mocks.state = 'expanded'
}) })
@@ -177,6 +187,20 @@ describe('VerticalNavLayout shell states', () => {
expect(revealedWrapper.get('.layout-navbar').attributes('data-shell-navbar-state')).toBe('revealed') 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 () => { it('keeps the footer contract stable across App and drawer shells', async () => {
mocks.appMode = true mocks.appMode = true
mocks.mdAndDown = true mocks.mdAndDown = true
@@ -0,0 +1,87 @@
<script lang="ts" setup>
const displacementMapMarkup = `
<svg viewBox="0 0 1200 80" xmlns="http://www.w3.org/2000/svg">
<defs>
<linearGradient id="x" x1="0%" y1="0%" x2="100%" y2="0%">
<stop offset="0%" stop-color="rgb(24 128 0)" />
<stop offset="8%" stop-color="rgb(128 128 0)" />
<stop offset="92%" stop-color="rgb(128 128 0)" />
<stop offset="100%" stop-color="rgb(232 128 0)" />
</linearGradient>
<linearGradient id="y" x1="0%" y1="0%" x2="0%" y2="100%">
<stop offset="0%" stop-color="rgb(0 0 28)" />
<stop offset="18%" stop-color="rgb(0 0 128)" />
<stop offset="82%" stop-color="rgb(0 0 128)" />
<stop offset="100%" stop-color="rgb(0 0 228)" />
</linearGradient>
</defs>
<rect width="1200" height="80" rx="20" fill="black" />
<rect width="1200" height="80" rx="20" fill="url(#x)" />
<rect width="1200" height="80" rx="20" fill="url(#y)" style="mix-blend-mode: screen" />
<rect
x="10"
y="10"
width="1180"
height="60"
rx="14"
fill="rgb(128 128 128)"
style="filter: blur(8px)"
/>
</svg>
`
const displacementMapUrl = `data:image/svg+xml,${encodeURIComponent(displacementMapMarkup)}`
</script>
<template>
<svg class="glass-navbar-refraction-defs" width="0" height="0" aria-hidden="true" focusable="false">
<defs>
<filter
id="glass-navbar-live-refraction-balanced"
x="-8%"
y="-80%"
width="116%"
height="260%"
color-interpolation-filters="sRGB"
>
<feImage
x="0"
y="0"
width="100%"
height="100%"
preserveAspectRatio="none"
:href="displacementMapUrl"
result="map"
/>
<feDisplacementMap in="SourceGraphic" in2="map" xChannelSelector="R" yChannelSelector="B" scale="-22" />
</filter>
<filter
id="glass-navbar-live-refraction-high"
x="-12%"
y="-100%"
width="124%"
height="300%"
color-interpolation-filters="sRGB"
>
<feImage
x="0"
y="0"
width="100%"
height="100%"
preserveAspectRatio="none"
:href="displacementMapUrl"
result="map"
/>
<feDisplacementMap in="SourceGraphic" in2="map" xChannelSelector="R" yChannelSelector="B" scale="-34" />
</filter>
</defs>
</svg>
</template>
<style scoped>
.glass-navbar-refraction-defs {
position: fixed;
overflow: hidden;
pointer-events: none;
}
</style>
@@ -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\);/, /\[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']")
})
}) })
+55
View File
@@ -1651,3 +1651,58 @@ html[data-theme='glass'] body[data-theme='glass'] .native-login-field:focus-with
transition: none; 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));
}
@@ -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)
})
})
+18
View File
@@ -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)
}