mirror of
https://github.com/jxxghp/MoviePilot-Frontend.git
synced 2026-09-10 10:16:44 +08:00
feat(glass): introduce V3 surface materials and responsive navbar optics
This commit is contained in:
@@ -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<ThemeCustomizerSettings>).detail.layout
|
||||
const settings = (event as CustomEvent<ThemeCustomizerSettings>).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'
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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'))
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user