diff --git a/src/@layouts/components/VerticalNavLayout.vue b/src/@layouts/components/VerticalNavLayout.vue index 4cad8e05..eec7ef6f 100644 --- a/src/@layouts/components/VerticalNavLayout.vue +++ b/src/@layouts/components/VerticalNavLayout.vue @@ -29,7 +29,9 @@ export default defineComponent({ const { footerDockHeight } = useFooterDockHeight() const fixedShellBackplate = useGlassFixedShellBackplate() const navbarRefractionMode = supportsGlassNavbarLiveRefraction() ? 'chromium' : 'goal1' - const themeLayout = ref(readThemeCustomizerSettings().layout) + const initialThemeSettings = readThemeCustomizerSettings() + const themeLayout = ref(initialThemeSettings.layout) + const shellTheme = ref(initialThemeSettings.theme) const canUseDesktopLayout = computed(() => !mdAndDown.value && !appMode.value) const isOverlayShell = computed(() => mdAndDown.value && !appMode.value) const isCollapsedLayout = computed(() => canUseDesktopLayout.value && themeLayout.value === 'collapsed') @@ -60,9 +62,22 @@ export default defineComponent({ const isDialogOpen = ref(false) let dialogObserver: MutationObserver | null = null const shellScroll = useShellScrollState({ scrollLocked: isDialogOpen }) + const isGlassFloatingAway = ref(false) + + // 桌面脱离窗口边缘是材质状态;复用滚动坐标,但不等待移动App的64px收起阈值。 + watch( + () => [shellScroll.scrollY.value, isFloatingNavbarEligible.value, shellTheme.value] as const, + ([scrollY, eligible, theme]) => { + if (!eligible || theme !== 'glass' || scrollY <= 4) isGlassFloatingAway.value = false + else if (scrollY >= 12) isGlassFloatingAway.value = true + }, + { immediate: true }, + ) const handleThemeCustomizerChange = (event: Event) => { - themeLayout.value = (event as CustomEvent).detail.layout + const settings = (event as CustomEvent).detail + themeLayout.value = settings.layout + shellTheme.value = settings.theme } // 监听弹窗状态变化 @@ -152,7 +167,10 @@ export default defineComponent({ // 👉 根据路由 meta 决定 footer 高度 const shouldShowFooter = !route.meta.hideFooter - const isNavbarAwayFromTop = shellScroll.state.value !== 'expanded' + const isNavbarAwayFromTop = + isFloatingNavbarEligible.value && shellTheme.value === 'glass' + ? isGlassFloatingAway.value + : shellScroll.state.value !== 'expanded' // compact/revealed 是 App 上下文顶栏的呈现状态;其他 Shell 只消费 away-from-top 材质状态。 const isNavbarCompact = appMode.value && shellScroll.state.value === 'compact' const isNavbarRevealed = appMode.value && shellScroll.state.value === 'revealed' diff --git a/src/@layouts/components/__tests__/VerticalNavLayout.spec.ts b/src/@layouts/components/__tests__/VerticalNavLayout.spec.ts index d5a5517e..3c1d5294 100644 --- a/src/@layouts/components/__tests__/VerticalNavLayout.spec.ts +++ b/src/@layouts/components/__tests__/VerticalNavLayout.spec.ts @@ -14,6 +14,7 @@ const mocks = vi.hoisted(() => ({ isWindowControlsOverlayMode: false, mdAndDown: false, navbarRefractionSupported: false, + scrollY: 0, revision: undefined as { value: number } | undefined, state: 'expanded' as 'expanded' | 'compact' | 'revealed', })) @@ -25,6 +26,10 @@ vi.mock('@/composables/useShellScrollState', async () => { useShellScrollState: () => ({ direction: computed(() => mocks.direction), state: computed(() => mocks.state), + scrollY: computed(() => { + void mocks.revision!.value + return mocks.scrollY + }), }), } }) @@ -153,6 +158,7 @@ describe('VerticalNavLayout shell states', () => { mocks.isWindowControlsOverlayMode = false mocks.mdAndDown = false mocks.navbarRefractionSupported = false + mocks.scrollY = 0 mocks.state = 'expanded' }) @@ -382,6 +388,26 @@ describe('VerticalNavLayout shell states', () => { expect(appWrapper.get('.layout-navbar').attributes()).toHaveProperty('inert') }) + it('responds to glass floating early with hysteresis without compacting App controls', async () => { + const wrapper = mountLayout() + window.dispatchEvent( + new CustomEvent('moviepilot:theme-customizer-change', { detail: { layout: 'horizontal', theme: 'glass' } }), + ) + await nextTick() + const root = wrapper.get('.layout-wrapper') + mocks.scrollY = 12 + await refreshShell() + expect(root.classes()).toContain('layout-navbar-away-from-top') + expect(wrapper.get('.layout-navbar').attributes('data-shell-navbar-state')).toBe('expanded') + mocks.scrollY = 8 + await refreshShell() + expect(root.classes()).toContain('layout-navbar-away-from-top') + mocks.scrollY = 4 + await refreshShell() + expect(root.classes()).not.toContain('layout-navbar-away-from-top') + wrapper.unmount() + }) + it('exposes floating eligibility only for an ordinary desktop horizontal environment', async () => { mocks.state = 'compact' const browserWrapper = mountLayout() diff --git a/src/components/theme/GlassNavbarRefractionDefs.vue b/src/components/theme/GlassNavbarRefractionDefs.vue index b1c9c63a..88c4fe68 100644 --- a/src/components/theme/GlassNavbarRefractionDefs.vue +++ b/src/components/theme/GlassNavbarRefractionDefs.vue @@ -156,8 +156,9 @@ function isRefractionActive(surface: NavigationSurface) { if (surface === 'navbar') { return ( - shell.classList.contains('layout-navbar-floating-eligible') && - shell.classList.contains('layout-navbar-away-from-top') + (shell.dataset.shellMode === 'desktop' && !shell.classList.contains('layout-horizontal-nav-active')) || + (shell.classList.contains('layout-navbar-floating-eligible') && + shell.classList.contains('layout-navbar-away-from-top')) ) } diff --git a/src/components/theme/__tests__/GlassNavbarRefractionDefs.spec.ts b/src/components/theme/__tests__/GlassNavbarRefractionDefs.spec.ts index 6dd63e16..91f3117b 100644 --- a/src/components/theme/__tests__/GlassNavbarRefractionDefs.spec.ts +++ b/src/components/theme/__tests__/GlassNavbarRefractionDefs.spec.ts @@ -146,6 +146,25 @@ describe('GlassNavbarRefractionDefs', () => { wrapper = mount(GlassNavbarRefractionDefs) } + it('enables a readable rectangular lens on the fixed desktop navbar', async () => { + shell.className = 'layout-wrapper' + shell.dataset.shellMode = 'desktop' + radius = 0 + mountWithSidebar() + await settle() + expectReadyForWidth(1423) + expectReadyForSidebar(260) + expect(createGlassNavbarDisplacementMap).toHaveBeenCalledWith( + expect.objectContaining({ width: 1423, height: 64, radius: 0 }), + ) + shell.classList.add('layout-overlay-nav') + shell.dataset.shellMode = 'drawer' + sidebar?.classList.add('overlay-nav') + await flushPromises() + await settle() + expect(shell.dataset.glassNavbarRefractionReady).toBe('false') + }) + it('uses computed pixel radius and activates only a decoded map with matching dimensions', async () => { wrapper = mount(GlassNavbarRefractionDefs) expect(shell.dataset.glassNavbarRefractionReady).toBe('false') diff --git a/src/styles/themes/_glass-v3.scss b/src/styles/themes/_glass-v3.scss new file mode 100644 index 00000000..d41c51e1 --- /dev/null +++ b/src/styles/themes/_glass-v3.scss @@ -0,0 +1,258 @@ +// V3 表面按信息角色统一材料;只改变承载层,不改变图表、业务数据与交互命中区。 +@mixin surfaces { + html[data-theme='glass'] { + --glass-v3-ink: 23, 27, 32; + --glass-v3-fill: clamp(0.1, calc(0.08 + var(--glass-surface-density, 0.62) * 0.12), 0.22); + --glass-v3-rim: clamp(0.12, calc(0.16 + var(--glass-reflection, 0.38) * 0.28), 0.4); + --glass-v3-sheen: clamp(0.04, calc(0.035 + var(--glass-reflection, 0.38) * 0.18), 0.2); + --glass-v3-shadow: 0 12px 30px rgba(0, 0, 0, 0.14); + --glass-v3-card-tint: 0; + --glass-v3-navigation-blur: clamp( + 0.65px, + calc(0.9px + var(--glass-surface-density, 0.62) * 0.6px - var(--glass-background-visibility, 0.58) * 0.25px), + 1.6px + ); + --glass-v3-navigation-filter: blur(var(--glass-v3-navigation-blur)) saturate(118%) + brightness(var(--glass-transmission-brightness, 1)); + --glass-v3-card-background: + linear-gradient(128deg, rgba(255, 255, 255, var(--glass-v3-sheen)), transparent 38%), + linear-gradient( + 135deg, + rgba(var(--glass-material-accent-rgb, var(--v-theme-primary)), var(--glass-v3-card-tint)), + rgba(var(--glass-material-accent-rgb, var(--v-theme-primary)), calc(var(--glass-v3-card-tint) * 0.35)) + ), + linear-gradient( + 180deg, + rgba(var(--glass-v3-ink), var(--glass-v3-fill)), + rgba(var(--glass-v3-ink), calc(var(--glass-v3-fill) + 0.055)) + ); + + &[data-glass-appearance='tinted'] { + --glass-v3-card-tint: clamp(0.025, calc(var(--glass-tint-density, 0.65) * 0.13), 0.15); + } + + &[data-glass-appearance='frosted'] { + --glass-v3-ink: 243, 246, 249; + --glass-v3-fill: clamp(0.08, calc(0.065 + var(--glass-surface-density, 0.86) * 0.11), 0.21); + --glass-v3-sheen: clamp(0.08, calc(0.07 + var(--glass-reflection, 0.35) * 0.22), 0.24); + --glass-v3-shadow: 0 12px 30px rgba(0, 0, 0, 0.1); + } + + body[data-theme='glass'] { + // 已有业务类是语义材料的适配入口;海报整图、内部图表和菜单不承载第二层卡片材料。 + .layout-page-content + .v-card:not( + .v-card .v-card, + .no-blur, + .media-card, + .playing-card, + .bg-primary, + .bg-success, + .bg-info, + .bg-warning, + .bg-error + ), + .layout-page-content :is(.site-card, .plugin-card, .downloading-card, .subscribe-card), + .dashboard-grid-content-measure > .v-card, + .dashboard-grid-content-measure > :first-child > .v-card { + border-radius: var(--app-theme-surface-radius, 20px) !important; + background: var(--glass-v3-card-background) !important; + box-shadow: + inset 0 1px 0 rgba(255, 255, 255, var(--glass-v3-rim)), + inset 1px 0 0 rgba(255, 255, 255, calc(var(--glass-v3-rim) * 0.6)), + inset -1px -2px 2px rgba(0, 0, 0, 0.16), + inset 0 0 0 1px rgba(255, 255, 255, calc(var(--glass-v3-rim) * 0.25)), + var(--glass-v3-shadow) !important; + } + + .layout-page-content + .v-card:not( + .v-card .v-card, + .no-blur, + .media-card, + .playing-card, + .bg-primary, + .bg-success, + .bg-info, + .bg-warning, + .bg-error + ):focus-within, + .layout-page-content :is(.site-card, .plugin-card, .downloading-card, .subscribe-card):focus-within, + .dashboard-grid-content-measure > .v-card:focus-within { + outline: 2px solid rgba(var(--v-theme-primary), 0.65); + outline-offset: 2px; + } + + .dashboard-grid-content-measure > .v-card :is(h4, h5, h6), + .site-card .font-semibold, + .downloading-card__metrics strong { + font-variant-numeric: tabular-nums; + } + + // 列表和图表内部使用平面分组,不叠加同一强度的高光、边框或阴影。 + .dashboard-grid-content-measure > .v-card .v-list { + background: transparent !important; + box-shadow: none !important; + backdrop-filter: none !important; + -webkit-backdrop-filter: none !important; + } + + .layout-page-content .site-card > .v-sheet { + background: rgba(var(--glass-v3-ink), 0.1) !important; + background-image: none !important; + box-shadow: none !important; + backdrop-filter: none !important; + -webkit-backdrop-filter: none !important; + border-inline-start: 1px solid rgba(255, 255, 255, 0.12); + } + + .layout-page-content .plugin-card__banner { + --plugin-card-banner-scrim: linear-gradient(rgba(23, 27, 32, 0.035), rgba(23, 27, 32, 0.1)); + --plugin-card-banner-tint: linear-gradient( + 125deg, + rgba(var(--plugin-card-effective-accent-rgb), 0.16), + rgba(var(--plugin-card-effective-accent-rgb), 0.045) 70% + ); + + border-block-end: 1px solid rgba(255, 255, 255, 0.12); + } + + .layout-page-content .plugin-card__banner .text-shadow { + text-shadow: none; + } + + .layout-page-content .plugin-card__plugin-icon { + border-radius: var(--app-control-radius, 14px); + box-shadow: + inset 0 1px 0 rgba(255, 255, 255, 0.25), + 0 5px 12px rgba(0, 0, 0, 0.12); + } + + .layout-page-content .media-card > .v-card-text { + // 电影画面仍可检查,文字所在的下半区才承担阅读吸收。 + background: linear-gradient( + 180deg, + rgba(23, 27, 32, 0.04), + rgba(23, 27, 32, 0.64) 52%, + rgba(23, 27, 32, 0.92) + ) !important; + } + } + } + + @media (hover: hover) { + html[data-theme='glass'] body[data-theme='glass'] { + .layout-page-content + .v-card:not( + .v-card .v-card, + .no-blur, + .media-card, + .playing-card, + .bg-primary, + .bg-success, + .bg-info, + .bg-warning, + .bg-error + ):hover, + .layout-page-content :is(.site-card, .plugin-card, .downloading-card, .subscribe-card):hover, + .dashboard-grid-content-measure > .v-card:hover, + .dashboard-grid-content-measure > :first-child > .v-card:hover { + --glass-v3-rim: clamp(0.2, calc(0.22 + var(--glass-reflection, 0.38) * 0.36), 0.54); + --glass-v3-shadow: 0 16px 34px rgba(0, 0, 0, 0.18); + } + } + } + + // 固定导航与已认可水平透镜使用同族材料;真实折射留在单个背景承载层。 + html[data-theme='glass']:is([data-glass-appearance='clear'], [data-glass-appearance='tinted']) + body[data-theme='glass'] + .layout-wrapper[data-shell-mode='desktop']:not(.layout-horizontal-nav-active) { + .layout-navbar { + --glass-navbar-live-filter: var(--glass-v3-navigation-filter); + + transform: none !important; + background: var(--glass-v3-card-background) !important; + backdrop-filter: var(--glass-navbar-live-filter) !important; + -webkit-backdrop-filter: var(--glass-navbar-live-filter) !important; + box-shadow: + inset 0 -1px 0 rgba(255, 255, 255, var(--glass-v3-rim)), + 0 8px 24px rgba(0, 0, 0, 0.1) !important; + } + + .layout-vertical-nav::before { + border-inline-end: 0; + background: var(--glass-v3-card-background) !important; + box-shadow: + inset -1px 0 0 rgba(255, 255, 255, var(--glass-v3-rim)), + inset -3px 0 4px rgba(255, 255, 255, 0.035), + 8px 0 24px rgba(0, 0, 0, 0.08) !important; + } + } + + @each $quality, $filter in ('balanced': 'balanced', 'high': 'high') { + html[data-theme='glass'][data-glass-quality='#{$quality}']:is( + [data-glass-appearance='clear'], + [data-glass-appearance='tinted'] + ) + body[data-theme='glass'] + .layout-wrapper[data-shell-mode='desktop'][data-glass-navbar-refraction='chromium'][data-glass-navbar-refraction-ready='true']:not( + .layout-horizontal-nav-active + ) + .layout-navbar { + --glass-navbar-live-filter: url('#glass-navbar-live-refraction-#{$filter}') var(--glass-v3-navigation-filter); + } + } + + // 稳定背板已承担背景吸收,侧栏用明亮散射层呈现厚度,避免再覆盖一层深色渐变。 + html[data-theme='glass'][data-glass-appearance='frosted'] body[data-theme='glass'] { + .layout-wrapper[data-shell-mode='desktop'] .layout-vertical-nav::before { + background: var(--glass-v3-card-background) !important; + box-shadow: + inset -1px 0 0 rgba(255, 255, 255, 0.32), + 8px 0 24px rgba(0, 0, 0, 0.07) !important; + } + } + + // 圆角在脱离边缘的首帧采用最终值,真实inset或transform继续使用同一150ms运动节奏。 + html[data-theme='glass']:is( + [data-glass-appearance='clear'], + [data-glass-appearance='tinted'], + [data-glass-appearance='frosted'] + ) + body[data-theme='glass'] + .layout-wrapper.layout-navbar-floating-eligible { + .layout-navbar, + .navbar-content-container { + transition-property: + transform, background-color, box-shadow, opacity, inset-block-start, inset-inline-start, inset-inline-end !important; + } + } + + @media (prefers-reduced-motion: reduce) { + html[data-theme='glass'] body[data-theme='glass'] .layout-wrapper.layout-navbar-floating-eligible { + .layout-navbar, + .navbar-content-container { + transition-duration: 0s !important; + } + } + } + + @media (prefers-reduced-transparency: reduce) { + html[data-theme='glass'] { + --glass-v3-navigation-filter: none !important; + --glass-v3-card-background: linear-gradient(rgb(31, 34, 39), rgb(31, 34, 39)) !important; + } + + html[data-theme='glass'] body[data-theme='glass'] { + .layout-wrapper[data-shell-mode='desktop'] .layout-navbar, + .layout-wrapper[data-shell-mode='desktop'] .layout-vertical-nav::before, + .layout-page-content :is(.site-card, .plugin-card, .downloading-card, .subscribe-card), + .dashboard-grid-content-measure > .v-card, + .dashboard-grid-content-measure > :first-child > .v-card { + background: rgb(31, 34, 39) !important; + backdrop-filter: none !important; + -webkit-backdrop-filter: none !important; + } + } + } +} diff --git a/src/styles/themes/glass.scss b/src/styles/themes/glass.scss index ba4f7042..f1470fa3 100644 --- a/src/styles/themes/glass.scss +++ b/src/styles/themes/glass.scss @@ -2,6 +2,7 @@ /* stylelint-disable scss/at-rule-no-unknown */ @use '@configured-variables' as variables; +@use 'glass-v3'; // 材质只覆盖统一表面 token;质量档只替换光学层,不改变业务组件契约。 html[data-theme='glass'] { @@ -1929,3 +1930,5 @@ html[data-theme='glass'][data-glass-appearance='tinted'] transition: none !important; } } + +@include glass-v3.surfaces;