mirror of
https://github.com/jxxghp/MoviePilot-Frontend.git
synced 2026-09-10 02:06:44 +08:00
Merge pull request #755 from InfinityPacer/codex/poc/liquid-glass-navbar-5050
This commit is contained in:
@@ -47,17 +47,6 @@
|
||||
"count": 3
|
||||
}
|
||||
},
|
||||
"src/App.vue": {
|
||||
"@typescript-eslint/no-explicit-any": {
|
||||
"count": 2
|
||||
},
|
||||
"@typescript-eslint/no-unused-vars": {
|
||||
"count": 1
|
||||
},
|
||||
"sonarjs/no-ignored-exceptions": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/ace-config.ts": {
|
||||
"@typescript-eslint/no-explicit-any": {
|
||||
"count": 24
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
import { useDisplay } from 'vuetify'
|
||||
import VerticalNav from '@layouts/components/VerticalNav.vue'
|
||||
import GlassFixedShellBackplate from '@/components/theme/GlassFixedShellBackplate.vue'
|
||||
import GlassNavbarRefractionDefs from '@/components/theme/GlassNavbarRefractionDefs.vue'
|
||||
import {
|
||||
readThemeCustomizerSettings,
|
||||
THEME_CUSTOMIZER_CHANGE_EVENT,
|
||||
@@ -11,6 +12,7 @@ import { useGlassFixedShellBackplate } from '@/composables/useGlassFixedShellBac
|
||||
import { usePWA } from '@/composables/usePWA'
|
||||
import { useShellScrollState } from '@/composables/useShellScrollState'
|
||||
import { useFooterDockHeight } from '@/composables/useFooterDockHeight'
|
||||
import { supportsGlassNavbarLiveRefraction } from '@/utils/glassNavbarRefraction'
|
||||
|
||||
const FLOATING_NAVBAR_INSET_PX = 16
|
||||
|
||||
@@ -26,7 +28,10 @@ export default defineComponent({
|
||||
// App Dock 通过 Teleport 挂载到 body,不参与内容流;将实际高度交给布局用于末尾避让。
|
||||
const { footerDockHeight } = useFooterDockHeight()
|
||||
const fixedShellBackplate = useGlassFixedShellBackplate()
|
||||
const themeLayout = ref(readThemeCustomizerSettings().layout)
|
||||
const navbarRefractionMode = supportsGlassNavbarLiveRefraction() ? 'chromium' : 'goal1'
|
||||
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')
|
||||
@@ -57,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
|
||||
}
|
||||
|
||||
// 监听弹窗状态变化
|
||||
@@ -149,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'
|
||||
@@ -208,6 +229,8 @@ export default defineComponent({
|
||||
? 'theme-qualified'
|
||||
: 'connected',
|
||||
'data-shell-scroll-direction': shellScroll.direction.value,
|
||||
'data-glass-navigation-refraction': navbarRefractionMode,
|
||||
'data-glass-navbar-refraction': navbarRefractionMode,
|
||||
style: {
|
||||
'--layout-footer-dock-height': `${footerDockHeight.value ?? 0}px`,
|
||||
'--shell-floating-navbar-scale-x': floatingNavbarScale.value,
|
||||
@@ -215,6 +238,7 @@ export default defineComponent({
|
||||
},
|
||||
},
|
||||
[
|
||||
navbarRefractionMode === 'chromium' ? h(GlassNavbarRefractionDefs) : null,
|
||||
fixedShellBackplateNode,
|
||||
verticalNav,
|
||||
h('div', { class: 'layout-content-wrapper' }, [navbar, main, footer]),
|
||||
|
||||
@@ -13,6 +13,8 @@ const mocks = vi.hoisted(() => ({
|
||||
isStandaloneMode: false,
|
||||
isWindowControlsOverlayMode: false,
|
||||
mdAndDown: false,
|
||||
navbarRefractionSupported: false,
|
||||
scrollY: 0,
|
||||
revision: undefined as { value: number } | undefined,
|
||||
state: 'expanded' as 'expanded' | 'compact' | 'revealed',
|
||||
}))
|
||||
@@ -24,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
|
||||
}),
|
||||
}),
|
||||
}
|
||||
})
|
||||
@@ -85,6 +91,10 @@ vi.mock('@/composables/useGlassFixedShellBackplate', async () => {
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/utils/glassNavbarRefraction', () => ({
|
||||
supportsGlassNavbarLiveRefraction: () => mocks.navbarRefractionSupported,
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/useThemeCustomizer', () => ({
|
||||
readThemeCustomizerSettings: () => ({ layout: 'vertical' }),
|
||||
THEME_CUSTOMIZER_CHANGE_EVENT: 'moviepilot:theme-customizer-change',
|
||||
@@ -94,6 +104,10 @@ vi.mock('@/components/theme/GlassFixedShellBackplate.vue', () => ({
|
||||
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', () => ({
|
||||
default: { template: '<aside data-testid="vertical-nav"><slot /></aside>' },
|
||||
}))
|
||||
@@ -143,6 +157,8 @@ describe('VerticalNavLayout shell states', () => {
|
||||
mocks.isStandaloneMode = false
|
||||
mocks.isWindowControlsOverlayMode = false
|
||||
mocks.mdAndDown = false
|
||||
mocks.navbarRefractionSupported = false
|
||||
mocks.scrollY = 0
|
||||
mocks.state = 'expanded'
|
||||
})
|
||||
|
||||
@@ -177,6 +193,20 @@ describe('VerticalNavLayout shell states', () => {
|
||||
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 () => {
|
||||
mocks.appMode = true
|
||||
mocks.mdAndDown = true
|
||||
@@ -358,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()
|
||||
|
||||
+28
-10
@@ -286,7 +286,20 @@ const shouldRenderGlassOpticalLayer = computed(
|
||||
isInitialRouteReady.value &&
|
||||
Boolean(activeBackgroundImage.value),
|
||||
)
|
||||
const GlassOpticalLayer = defineAsyncComponent(() => import('@/components/theme/GlassOpticalLayer.vue'))
|
||||
const loadGlassOpticalLayer = () => import('@/components/theme/GlassOpticalLayer.vue')
|
||||
const GlassOpticalLayer = defineAsyncComponent(loadGlassOpticalLayer)
|
||||
|
||||
// 模块下载与壁纸准备并行;实际挂载仍等待路由和壁纸,CSS 档不请求光学组件。
|
||||
watch(
|
||||
() => isGlassTheme.value && opticalQuality.value !== 'css',
|
||||
enabled => {
|
||||
if (!enabled) return
|
||||
void loadGlassOpticalLayer().catch(error => {
|
||||
console.warn('[Glass] Optical component preload failed', error)
|
||||
})
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
const transparentBackgroundBlur = ref(16)
|
||||
const transparencyGlassQuality = ref<TransparencyGlassQuality>(
|
||||
localStorage.getItem('transparency-glass-quality') === 'realtime' ? 'realtime' : 'lightweight',
|
||||
@@ -864,13 +877,15 @@ async function removeLoadingWithStateCheck() {
|
||||
globalLoadingStateManager.setLoadingState('pwa-state', true)
|
||||
|
||||
// 静默检查PWA状态恢复,但不能让恢复异常或慢请求挡住应用外壳。
|
||||
const pwaController = (window as any).pwaStateController
|
||||
const pwaController = (
|
||||
window as Window & {
|
||||
/** 宿主可选的状态恢复钩子;启动预算到期后不阻塞外壳。 */
|
||||
pwaStateController?: { waitForStateRestore?: () => unknown }
|
||||
}
|
||||
).pwaStateController
|
||||
if (pwaController?.waitForStateRestore) {
|
||||
await waitForLaunchTask(
|
||||
Promise.resolve().then(() => pwaController.waitForStateRestore()),
|
||||
getRemainingLaunchBudget(),
|
||||
'PWA state restore',
|
||||
)
|
||||
const restoreState = pwaController.waitForStateRestore.bind(pwaController)
|
||||
await waitForLaunchTask(Promise.resolve().then(restoreState), getRemainingLaunchBudget(), 'PWA state restore')
|
||||
}
|
||||
globalLoadingStateManager.setLoadingState('pwa-state', false)
|
||||
|
||||
@@ -893,7 +908,7 @@ async function removeLoadingWithStateCheck() {
|
||||
checkAndEmitUnreadMessages()
|
||||
}
|
||||
} catch (error) {
|
||||
// 即使出错也要移除加载界面
|
||||
console.warn('[Launch] State checks failed; revealing the application shell', error)
|
||||
globalLoadingStateManager.reset()
|
||||
await animateAndRemoveLoader()
|
||||
}
|
||||
@@ -927,9 +942,12 @@ async function loadBackgroundImages(loadVersion: number, retryCount = 0) {
|
||||
resetBackgroundCrossfade()
|
||||
recordGlassLaunchTiming('wallpaper-committed', activeBackgroundImage.value)
|
||||
startBackgroundRotation()
|
||||
} catch (error: any) {
|
||||
} catch (error: unknown) {
|
||||
if (loadVersion !== backgroundLoadVersion) return
|
||||
const isAbortError = error.name === 'AbortError' || error.code === 'ERR_CANCELED'
|
||||
const isAbortError =
|
||||
typeof error === 'object' &&
|
||||
error !== null &&
|
||||
(('name' in error && error.name === 'AbortError') || ('code' in error && error.code === 'ERR_CANCELED'))
|
||||
if (retryCount < maxRetries) {
|
||||
const baseDelay = isAbortError ? 1000 : 3000
|
||||
const retryDelay = Math.min(baseDelay * Math.pow(2, retryCount), 10000)
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<script lang="ts" setup>
|
||||
import { computed, nextTick, onBeforeUnmount, onMounted, ref, useId, watch } from 'vue'
|
||||
import type { GlassFixedShellBackplateLayer } from '@/composables/useGlassFixedShellBackplate'
|
||||
|
||||
interface Props {
|
||||
@@ -12,17 +13,292 @@ interface Props {
|
||||
transitionDurationMs: number
|
||||
}
|
||||
|
||||
/** 共用稳定壁纸背板、但拥有独立外轮廓的导航表面。 */
|
||||
type GeometrySurface = 'sidebar' | 'navbar'
|
||||
|
||||
/** SVG objectBoundingBox 坐标,分别以背板的实际宽和高归一化。 */
|
||||
interface NormalizedClipRect {
|
||||
/** 可见高度占背板高度的比例。 */
|
||||
height: number
|
||||
/** 水平方向圆角半径占背板宽度的比例。 */
|
||||
rx: number
|
||||
/** 垂直方向圆角半径占背板高度的比例。 */
|
||||
ry: number
|
||||
/** 可见宽度占背板宽度的比例。 */
|
||||
width: number
|
||||
/** 相对背板左边缘的位置。 */
|
||||
x: number
|
||||
/** 相对背板上边缘的位置。 */
|
||||
y: number
|
||||
}
|
||||
|
||||
/** 背板实际CSS像素边界,不使用可能包含滚动条的window.innerWidth。 */
|
||||
interface BackplateBounds {
|
||||
/** 真实高度。 */
|
||||
height: number
|
||||
/** 实际左侧视口坐标。 */
|
||||
left: number
|
||||
/** 实际顶部视口坐标。 */
|
||||
top: number
|
||||
/** 排除滚动条后的实际宽度。 */
|
||||
width: number
|
||||
}
|
||||
|
||||
const GEOMETRY_SURFACES: readonly GeometrySurface[] = ['sidebar', 'navbar']
|
||||
const GEOMETRY_ATTRIBUTE_FILTER = [
|
||||
'class',
|
||||
'data-glass-appearance',
|
||||
'data-glass-quality',
|
||||
'data-shell-display-environment',
|
||||
'data-shell-mode',
|
||||
'data-shell-navbar-attachment',
|
||||
'data-theme',
|
||||
'style',
|
||||
]
|
||||
|
||||
const props = defineProps<Props>()
|
||||
const mainBackplateRef = ref<HTMLElement | null>(null)
|
||||
const clipRects = ref<NormalizedClipRect[]>([])
|
||||
const clipPathId = `glass-fixed-shell-clip-${useId().replace(/[^a-zA-Z0-9_-]/gu, '-')}`
|
||||
const transitionStyle = computed(() => ({
|
||||
'--glass-fixed-shell-transition-duration': `${Math.max(0, props.transitionDurationMs)}ms`,
|
||||
}))
|
||||
const mainBackplateStyle = computed(() => ({
|
||||
...transitionStyle.value,
|
||||
clipPath: clipRects.value.length === GEOMETRY_SURFACES.length ? `url(#${clipPathId})` : undefined,
|
||||
}))
|
||||
|
||||
const observedElements: Record<GeometrySurface, HTMLElement | null> = {
|
||||
navbar: null,
|
||||
sidebar: null,
|
||||
}
|
||||
const transitionHandlers: Record<GeometrySurface, EventListener | null> = {
|
||||
navbar: null,
|
||||
sidebar: null,
|
||||
}
|
||||
|
||||
let isMounted = false
|
||||
let observedShell: HTMLElement | null = null
|
||||
let resizeObserver: ResizeObserver | null = null
|
||||
let stateObserver: MutationObserver | null = null
|
||||
let geometrySyncQueued = false
|
||||
let usesResizeFallback = false
|
||||
let lastGeometryKey = ''
|
||||
|
||||
function getLayoutShell() {
|
||||
return mainBackplateRef.value?.closest('.layout-wrapper') as HTMLElement | null
|
||||
}
|
||||
|
||||
/** 仅为桌面连接式导航启用几何裁剪,其他壳层继续使用主题原有 CSS 裁剪。 */
|
||||
function isConnectedDesktopShell(shell: HTMLElement) {
|
||||
return (
|
||||
document.documentElement.dataset.theme === 'glass' &&
|
||||
!props.isOverlayNav &&
|
||||
shell.dataset.shellMode === 'desktop' &&
|
||||
shell.dataset.shellNavbarAttachment === 'connected' &&
|
||||
!shell.classList.contains('layout-horizontal-nav-active') &&
|
||||
!shell.classList.contains('layout-overlay-nav') &&
|
||||
!shell.classList.contains('layout-app-shell') &&
|
||||
!shell.classList.contains('layout-window-controls-overlay-shell')
|
||||
)
|
||||
}
|
||||
|
||||
function readBackplateBounds(): BackplateBounds | null {
|
||||
const backplate = mainBackplateRef.value
|
||||
if (!backplate) return null
|
||||
|
||||
const bounds = backplate.getBoundingClientRect()
|
||||
const width = Number.isFinite(bounds.width) ? Math.max(0, bounds.width) : 0
|
||||
const height = Number.isFinite(bounds.height) ? Math.max(0, bounds.height) : 0
|
||||
if (width <= 0 || height <= 0) return null
|
||||
|
||||
return {
|
||||
height,
|
||||
left: Number.isFinite(bounds.left) ? bounds.left : 0,
|
||||
top: Number.isFinite(bounds.top) ? bounds.top : 0,
|
||||
width,
|
||||
}
|
||||
}
|
||||
|
||||
function clamp(value: number, minimum: number, maximum: number) {
|
||||
return Math.min(Math.max(value, minimum), maximum)
|
||||
}
|
||||
|
||||
/** 固定导航四角共用等半径;读取计算后的像素值与CSS轮廓保持一致。 */
|
||||
function readComputedRadius(element: HTMLElement) {
|
||||
const radius = Number.parseFloat(window.getComputedStyle(element).borderTopLeftRadius)
|
||||
return Number.isFinite(radius) ? Math.max(0, radius) : 0
|
||||
}
|
||||
|
||||
function readNormalizedClipRect(element: HTMLElement, backplate: BackplateBounds) {
|
||||
const bounds = element.getBoundingClientRect()
|
||||
const left = Number.isFinite(bounds.left) ? bounds.left : 0
|
||||
const top = Number.isFinite(bounds.top) ? bounds.top : 0
|
||||
const width = Number.isFinite(bounds.width) ? Math.max(0, bounds.width) : 0
|
||||
const height = Number.isFinite(bounds.height) ? Math.max(0, bounds.height) : 0
|
||||
const right = Number.isFinite(bounds.right) ? bounds.right : left + width
|
||||
const bottom = Number.isFinite(bounds.bottom) ? bounds.bottom : top + height
|
||||
const visibleLeft = clamp(Math.min(left, right) - backplate.left, 0, backplate.width)
|
||||
const visibleTop = clamp(Math.min(top, bottom) - backplate.top, 0, backplate.height)
|
||||
const visibleRight = clamp(Math.max(left, right) - backplate.left, 0, backplate.width)
|
||||
const visibleBottom = clamp(Math.max(top, bottom) - backplate.top, 0, backplate.height)
|
||||
const visibleWidth = visibleRight - visibleLeft
|
||||
const visibleHeight = visibleBottom - visibleTop
|
||||
|
||||
if (visibleWidth <= 0 || visibleHeight <= 0) return null
|
||||
|
||||
const radius = readComputedRadius(element)
|
||||
const normalizedWidth = visibleWidth / backplate.width
|
||||
const normalizedHeight = visibleHeight / backplate.height
|
||||
|
||||
return {
|
||||
height: normalizedHeight,
|
||||
rx: clamp(radius / backplate.width, 0, normalizedWidth / 2),
|
||||
ry: clamp(radius / backplate.height, 0, normalizedHeight / 2),
|
||||
width: normalizedWidth,
|
||||
x: visibleLeft / backplate.width,
|
||||
y: visibleTop / backplate.height,
|
||||
} satisfies NormalizedClipRect
|
||||
}
|
||||
|
||||
function bindGeometrySurface(surface: GeometrySurface, element: HTMLElement | null) {
|
||||
const previousElement = observedElements[surface]
|
||||
if (previousElement === element) return
|
||||
|
||||
if (resizeObserver && previousElement) resizeObserver.unobserve(previousElement)
|
||||
const previousHandler = transitionHandlers[surface]
|
||||
if (previousElement && previousHandler) {
|
||||
previousElement.removeEventListener('transitionrun', previousHandler)
|
||||
previousElement.removeEventListener('transitionend', previousHandler)
|
||||
previousElement.removeEventListener('transitioncancel', previousHandler)
|
||||
}
|
||||
|
||||
observedElements[surface] = element
|
||||
transitionHandlers[surface] = null
|
||||
if (!element) return
|
||||
|
||||
resizeObserver?.observe(element)
|
||||
const transitionHandler: EventListener = () => scheduleGeometrySync()
|
||||
transitionHandlers[surface] = transitionHandler
|
||||
element.addEventListener('transitionrun', transitionHandler)
|
||||
element.addEventListener('transitionend', transitionHandler)
|
||||
element.addEventListener('transitioncancel', transitionHandler)
|
||||
}
|
||||
|
||||
function observeStateSources() {
|
||||
if (!stateObserver) return
|
||||
|
||||
stateObserver.disconnect()
|
||||
stateObserver.observe(document.documentElement, {
|
||||
attributeFilter: GEOMETRY_ATTRIBUTE_FILTER,
|
||||
attributes: true,
|
||||
})
|
||||
if (document.body) {
|
||||
stateObserver.observe(document.body, {
|
||||
attributeFilter: GEOMETRY_ATTRIBUTE_FILTER,
|
||||
attributes: true,
|
||||
})
|
||||
}
|
||||
if (observedShell) {
|
||||
stateObserver.observe(observedShell, {
|
||||
attributeFilter: GEOMETRY_ATTRIBUTE_FILTER,
|
||||
attributes: true,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
function refreshObservedElements() {
|
||||
const shell = getLayoutShell()
|
||||
if (shell !== observedShell) {
|
||||
observedShell = shell
|
||||
observeStateSources()
|
||||
}
|
||||
|
||||
bindGeometrySurface('sidebar', shell?.querySelector<HTMLElement>('.layout-vertical-nav:not(.overlay-nav)') ?? null)
|
||||
bindGeometrySurface('navbar', shell?.querySelector<HTMLElement>('.layout-navbar') ?? null)
|
||||
}
|
||||
|
||||
function applyGeometry(nextRects: NormalizedClipRect[]) {
|
||||
const nextKey = JSON.stringify(nextRects)
|
||||
if (nextKey === lastGeometryKey) return
|
||||
|
||||
lastGeometryKey = nextKey
|
||||
clipRects.value = nextRects
|
||||
}
|
||||
|
||||
function syncGeometry() {
|
||||
if (!isMounted) return
|
||||
|
||||
refreshObservedElements()
|
||||
const shell = observedShell
|
||||
const backplate = readBackplateBounds()
|
||||
const sidebar = observedElements.sidebar
|
||||
const navbar = observedElements.navbar
|
||||
|
||||
if (!shell || !backplate || !isConnectedDesktopShell(shell) || !sidebar || !navbar) {
|
||||
applyGeometry([])
|
||||
return
|
||||
}
|
||||
|
||||
const nextRects = GEOMETRY_SURFACES.map(surface =>
|
||||
readNormalizedClipRect(observedElements[surface] as HTMLElement, backplate),
|
||||
)
|
||||
if (nextRects.some(rect => rect === null)) {
|
||||
applyGeometry([])
|
||||
return
|
||||
}
|
||||
|
||||
applyGeometry(nextRects as NormalizedClipRect[])
|
||||
}
|
||||
|
||||
function scheduleGeometrySync() {
|
||||
if (geometrySyncQueued) return
|
||||
|
||||
geometrySyncQueued = true
|
||||
queueMicrotask(() => {
|
||||
geometrySyncQueued = false
|
||||
syncGeometry()
|
||||
})
|
||||
}
|
||||
|
||||
watch(() => props.isOverlayNav, scheduleGeometrySync, { flush: 'sync' })
|
||||
|
||||
onMounted(() => {
|
||||
isMounted = true
|
||||
stateObserver = new MutationObserver(scheduleGeometrySync)
|
||||
resizeObserver = typeof ResizeObserver === 'undefined' ? null : new ResizeObserver(() => scheduleGeometrySync())
|
||||
usesResizeFallback = resizeObserver === null
|
||||
if (usesResizeFallback) window.addEventListener('resize', scheduleGeometrySync, { passive: true })
|
||||
else if (mainBackplateRef.value) resizeObserver?.observe(mainBackplateRef.value)
|
||||
|
||||
refreshObservedElements()
|
||||
observeStateSources()
|
||||
syncGeometry()
|
||||
void nextTick(syncGeometry)
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
isMounted = false
|
||||
stateObserver?.disconnect()
|
||||
stateObserver = null
|
||||
resizeObserver?.disconnect()
|
||||
resizeObserver = null
|
||||
if (usesResizeFallback) window.removeEventListener('resize', scheduleGeometrySync)
|
||||
usesResizeFallback = false
|
||||
|
||||
for (const surface of GEOMETRY_SURFACES) bindGeometrySurface(surface, null)
|
||||
observedShell = null
|
||||
geometrySyncQueued = false
|
||||
lastGeometryKey = ''
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
ref="mainBackplateRef"
|
||||
class="glass-fixed-shell-backplate glass-fixed-shell-backplate--main"
|
||||
data-backplate-surface="main"
|
||||
:style="transitionStyle"
|
||||
:style="mainBackplateStyle"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<div
|
||||
@@ -46,6 +322,24 @@ const transitionStyle = computed(() => ({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<svg class="glass-fixed-shell-backplate__geometry" width="0" height="0" aria-hidden="true" focusable="false">
|
||||
<defs>
|
||||
<clipPath :id="clipPathId" clipPathUnits="objectBoundingBox">
|
||||
<rect
|
||||
v-for="(rect, index) in clipRects"
|
||||
:key="GEOMETRY_SURFACES[index]"
|
||||
:data-clip-surface="GEOMETRY_SURFACES[index]"
|
||||
:height="rect.height"
|
||||
:rx="rect.rx"
|
||||
:ry="rect.ry"
|
||||
:width="rect.width"
|
||||
:x="rect.x"
|
||||
:y="rect.y"
|
||||
/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
|
||||
<div
|
||||
v-if="isOverlayNav"
|
||||
class="glass-fixed-shell-backplate glass-fixed-shell-backplate--overlay-nav"
|
||||
@@ -89,6 +383,12 @@ const transitionStyle = computed(() => ({
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.glass-fixed-shell-backplate__geometry {
|
||||
position: fixed;
|
||||
overflow: hidden;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.glass-fixed-shell-backplate--main {
|
||||
--glass-fixed-shell-nav-inline-size: #{variables.$layout-vertical-nav-width};
|
||||
|
||||
|
||||
@@ -0,0 +1,510 @@
|
||||
<script lang="ts" setup>
|
||||
import type { Ref } from 'vue'
|
||||
import {
|
||||
createGlassNavbarDisplacementMap,
|
||||
getGlassNavbarOpticalResponse,
|
||||
NEUTRAL_GLASS_NAVBAR_DISPLACEMENT_MAP,
|
||||
} from '@/utils/glassNavbarRefraction'
|
||||
import { useEffectiveGlassSettings } from '@/composables/useThemeCustomizer'
|
||||
|
||||
/** 独立持有几何、位移图和就绪状态的导航表面。 */
|
||||
type NavigationSurface = 'navbar' | 'sidebar'
|
||||
|
||||
interface NavigationSurfaceState {
|
||||
/** 已解码缓存对应的完整几何与光学参数键。 */
|
||||
cachedGeometry: string
|
||||
/** 已成功解码、允许重新激活的 PNG。 */
|
||||
cachedMap: string
|
||||
/** 初始图像尺寸与计算样式不可用时的半径回退。 */
|
||||
defaultGeometry: {
|
||||
height: number
|
||||
radius: number
|
||||
width: number
|
||||
}
|
||||
/** 被观察的真实导航元素,前景控件不参与位移。 */
|
||||
element: HTMLElement | null
|
||||
/** 避免相同失败输入反复解码的键。 */
|
||||
failedGeometry: string
|
||||
/** 必须全部结束后才允许激活最终几何的过渡属性。 */
|
||||
geometryTransitions: Set<string>
|
||||
/** 输入改变时解除失败重试抑制的上一份几何键。 */
|
||||
lastObservedGeometry: string
|
||||
/** 使过时解码结果失效的单调版本。 */
|
||||
mapRevision: number
|
||||
/** 与 feImage 同批提交的 CSS 像素尺寸。 */
|
||||
mapSize: { height: number; width: number }
|
||||
/** 当前绑定到 feImage 的位移图。 */
|
||||
mapUrl: Ref<string>
|
||||
/** 正在解码的输入,重复尺寸通知不会将它取消。 */
|
||||
pendingGeometry: string
|
||||
/** CSS 只在该属性为 true 时激活对应 SVG。 */
|
||||
readyAttribute: string
|
||||
/** 承载布局资格与就绪属性的外壳。 */
|
||||
shell: HTMLElement | null
|
||||
}
|
||||
|
||||
const settings = useEffectiveGlassSettings()
|
||||
const opticalResponse = computed(() =>
|
||||
getGlassNavbarOpticalResponse({
|
||||
deformation: settings.value.glassDeformationStrength,
|
||||
translation: settings.value.glassTranslationStrength,
|
||||
}),
|
||||
)
|
||||
|
||||
const DEFAULT_NAVBAR_GEOMETRY = {
|
||||
height: 64,
|
||||
radius: 16,
|
||||
width: 1200,
|
||||
}
|
||||
const DEFAULT_SIDEBAR_GEOMETRY = {
|
||||
height: 800,
|
||||
radius: 0,
|
||||
width: 260,
|
||||
}
|
||||
const MAP_RESIZE_SETTLE_MS = 60
|
||||
const OBSERVED_SIZE_STYLE_PROPERTIES = [
|
||||
'--shell-floating-navbar-radius',
|
||||
'--shell-floating-navbar-inset',
|
||||
'--layout-navbar-block-size',
|
||||
'--layout-navbar-safe-area-top',
|
||||
'--navbar-tab-height',
|
||||
'--layout-vertical-nav-width',
|
||||
'--layout-vertical-nav-collapsed-width',
|
||||
'border-radius',
|
||||
'border-start-start-radius',
|
||||
'width',
|
||||
'height',
|
||||
'inline-size',
|
||||
'block-size',
|
||||
]
|
||||
const navbarMapUrl = ref(NEUTRAL_GLASS_NAVBAR_DISPLACEMENT_MAP)
|
||||
const sidebarMapUrl = ref(NEUTRAL_GLASS_NAVBAR_DISPLACEMENT_MAP)
|
||||
const navbarMapSize = reactive({
|
||||
height: DEFAULT_NAVBAR_GEOMETRY.height,
|
||||
width: DEFAULT_NAVBAR_GEOMETRY.width,
|
||||
})
|
||||
const sidebarMapSize = reactive({
|
||||
height: DEFAULT_SIDEBAR_GEOMETRY.height,
|
||||
width: DEFAULT_SIDEBAR_GEOMETRY.width,
|
||||
})
|
||||
|
||||
let observedShell: HTMLElement | null = null
|
||||
let resizeObserver: ResizeObserver | null = null
|
||||
let stateObserver: MutationObserver | null = null
|
||||
let resizeTimer: ReturnType<typeof setTimeout> | null = null
|
||||
let transparencyQuery: MediaQueryList | null = null
|
||||
const navbarState: NavigationSurfaceState = {
|
||||
cachedGeometry: '',
|
||||
cachedMap: NEUTRAL_GLASS_NAVBAR_DISPLACEMENT_MAP,
|
||||
defaultGeometry: DEFAULT_NAVBAR_GEOMETRY,
|
||||
element: null,
|
||||
failedGeometry: '',
|
||||
geometryTransitions: new Set(),
|
||||
lastObservedGeometry: '',
|
||||
mapRevision: 0,
|
||||
mapSize: navbarMapSize,
|
||||
mapUrl: navbarMapUrl,
|
||||
pendingGeometry: '',
|
||||
readyAttribute: 'data-glass-navbar-refraction-ready',
|
||||
shell: null,
|
||||
}
|
||||
const sidebarState: NavigationSurfaceState = {
|
||||
cachedGeometry: '',
|
||||
cachedMap: NEUTRAL_GLASS_NAVBAR_DISPLACEMENT_MAP,
|
||||
defaultGeometry: DEFAULT_SIDEBAR_GEOMETRY,
|
||||
element: null,
|
||||
failedGeometry: '',
|
||||
geometryTransitions: new Set(),
|
||||
lastObservedGeometry: '',
|
||||
mapRevision: 0,
|
||||
mapSize: sidebarMapSize,
|
||||
mapUrl: sidebarMapUrl,
|
||||
pendingGeometry: '',
|
||||
readyAttribute: 'data-glass-sidebar-refraction-ready',
|
||||
shell: null,
|
||||
}
|
||||
const surfaceStates: Record<NavigationSurface, NavigationSurfaceState> = {
|
||||
navbar: navbarState,
|
||||
sidebar: sidebarState,
|
||||
}
|
||||
const SURFACE_KEYS: readonly NavigationSurface[] = ['navbar', 'sidebar']
|
||||
const transitionHandlers: Record<NavigationSurface, EventListener | null> = {
|
||||
navbar: null,
|
||||
sidebar: null,
|
||||
}
|
||||
|
||||
function getSurfaceState(surface: NavigationSurface) {
|
||||
return surfaceStates[surface]
|
||||
}
|
||||
|
||||
/** 只有桌面清透/色调导航进入 SVG 位移增强;磨砂固定层沿用稳定背板或原生 CSS。 */
|
||||
function isRefractionActive(surface: NavigationSurface) {
|
||||
const { theme, glassAppearance, glassQuality } = document.documentElement.dataset
|
||||
const state = getSurfaceState(surface)
|
||||
const shell = state.shell
|
||||
const element = state.element
|
||||
|
||||
if (
|
||||
!element ||
|
||||
!shell ||
|
||||
theme !== 'glass' ||
|
||||
(glassAppearance !== 'clear' && glassAppearance !== 'tinted') ||
|
||||
(glassQuality !== 'balanced' && glassQuality !== 'high') ||
|
||||
transparencyQuery?.matches
|
||||
)
|
||||
return false
|
||||
|
||||
if (surface === 'navbar') {
|
||||
return (
|
||||
(shell.dataset.shellMode === 'desktop' &&
|
||||
!shell.classList.contains('layout-horizontal-nav-active') &&
|
||||
!shell.classList.contains('layout-window-controls-overlay-shell')) ||
|
||||
(shell.classList.contains('layout-navbar-floating-eligible') &&
|
||||
shell.classList.contains('layout-navbar-away-from-top'))
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
!element.classList.contains('overlay-nav') &&
|
||||
!shell.classList.contains('layout-overlay-nav') &&
|
||||
!shell.classList.contains('layout-app-shell') &&
|
||||
!shell.classList.contains('layout-horizontal-nav-active')
|
||||
)
|
||||
}
|
||||
|
||||
/** 几何或状态变化后立即撤销对应 map,避免另一个尺寸继续采样旧位移场。 */
|
||||
function invalidateDisplacementMap(surface: NavigationSurface) {
|
||||
const state = getSurfaceState(surface)
|
||||
state.mapRevision += 1
|
||||
state.pendingGeometry = ''
|
||||
state.shell?.setAttribute(state.readyAttribute, 'false')
|
||||
}
|
||||
|
||||
function getInlineStyleValue(styleText: string | null, property: string) {
|
||||
const declarations = document.createElement('div').style
|
||||
declarations.cssText = styleText ?? ''
|
||||
return declarations.getPropertyValue(property).trim()
|
||||
}
|
||||
|
||||
/** 滚动缩放变量不改变真实采样几何,只有尺寸声明变化才撤销当前 map。 */
|
||||
function hasObservedSizeStyleChange(record: MutationRecord) {
|
||||
if (record.attributeName !== 'style') return true
|
||||
|
||||
const target = record.target as Element
|
||||
const currentStyle = target.getAttribute('style')
|
||||
|
||||
return OBSERVED_SIZE_STYLE_PROPERTIES.some(
|
||||
property => getInlineStyleValue(record.oldValue, property) !== getInlineStyleValue(currentStyle, property),
|
||||
)
|
||||
}
|
||||
|
||||
function handleStateMutations(records: MutationRecord[]) {
|
||||
if (records.some(hasObservedSizeStyleChange)) scheduleDisplacementMapSync()
|
||||
}
|
||||
|
||||
/** 读取真实表面边界;圆角使用计算后的 CSS 像素,矩形侧栏明确传入 0。 */
|
||||
function readDisplacementGeometry(surface: NavigationSurface) {
|
||||
const state = getSurfaceState(surface)
|
||||
if (!state.element) return null
|
||||
const bounds = state.element.getBoundingClientRect()
|
||||
const styles = getComputedStyle(state.element)
|
||||
// 自定义属性可能保留 rem;只有计算后的圆角与位移图使用同一 CSS 像素坐标。
|
||||
const borderRadius = Number.parseFloat(styles.borderStartStartRadius)
|
||||
const height = Math.max(1, Math.round(bounds.height))
|
||||
const width = Math.max(1, Math.round(bounds.width))
|
||||
|
||||
const radius = Number.isFinite(borderRadius) ? borderRadius : state.defaultGeometry.radius
|
||||
const optics = opticalResponse.value
|
||||
return {
|
||||
height,
|
||||
radius,
|
||||
width,
|
||||
optics,
|
||||
key: `${width}:${height}:${radius}:${optics.horizontalRatio}:${optics.verticalRatio}:${optics.translationPx}`,
|
||||
}
|
||||
}
|
||||
|
||||
/** map 与 feImage 尺寸同批更新;解码失败或过期结果继续使用 CSS 材质。 */
|
||||
async function syncDisplacementMap(surface: NavigationSurface) {
|
||||
const state = getSurfaceState(surface)
|
||||
if (!isRefractionActive(surface) || state.geometryTransitions.size > 0) return
|
||||
const geometry = readDisplacementGeometry(surface)
|
||||
if (!geometry || state.pendingGeometry === geometry.key) return
|
||||
const { height, radius, width, optics, key: geometryKey } = geometry
|
||||
const revision = ++state.mapRevision
|
||||
state.pendingGeometry = geometryKey
|
||||
if (state.lastObservedGeometry !== geometryKey) {
|
||||
state.lastObservedGeometry = geometryKey
|
||||
state.failedGeometry = ''
|
||||
}
|
||||
|
||||
try {
|
||||
if (state.cachedGeometry !== geometryKey) {
|
||||
if (state.failedGeometry === geometryKey) return
|
||||
const map = createGlassNavbarDisplacementMap({ height, radius, width, optics })
|
||||
if (map === NEUTRAL_GLASS_NAVBAR_DISPLACEMENT_MAP) {
|
||||
state.failedGeometry = geometryKey
|
||||
invalidateDisplacementMap(surface)
|
||||
return
|
||||
}
|
||||
const decoded = new Image()
|
||||
decoded.src = map
|
||||
await decoded.decode()
|
||||
if (revision !== state.mapRevision || !isRefractionActive(surface)) return
|
||||
state.cachedGeometry = geometryKey
|
||||
state.cachedMap = map
|
||||
state.failedGeometry = ''
|
||||
}
|
||||
state.mapSize.height = height
|
||||
state.mapSize.width = width
|
||||
state.mapUrl.value = state.cachedMap
|
||||
await nextTick()
|
||||
if (revision === state.mapRevision && isRefractionActive(surface)) {
|
||||
state.shell?.setAttribute(state.readyAttribute, 'true')
|
||||
}
|
||||
} catch {
|
||||
// 位移是增强能力;图片解码失败不阻断导航和原生玻璃表面。
|
||||
if (revision === state.mapRevision) {
|
||||
state.failedGeometry = geometryKey
|
||||
invalidateDisplacementMap(surface)
|
||||
}
|
||||
} finally {
|
||||
if (revision === state.mapRevision) state.pendingGeometry = ''
|
||||
}
|
||||
}
|
||||
|
||||
function syncDisplacementMaps() {
|
||||
for (const surface of SURFACE_KEYS) void syncDisplacementMap(surface)
|
||||
}
|
||||
|
||||
// 连续 resize 需要合并;相同几何的通知不撤销已就绪或正在解码的位移图。
|
||||
function scheduleDisplacementMapSync() {
|
||||
if (resizeTimer !== null) clearTimeout(resizeTimer)
|
||||
resizeTimer = null
|
||||
|
||||
let shouldSync = false
|
||||
for (const surface of SURFACE_KEYS) {
|
||||
const state = getSurfaceState(surface)
|
||||
if (!isRefractionActive(surface) || state.geometryTransitions.size > 0) {
|
||||
if (state.element) invalidateDisplacementMap(surface)
|
||||
continue
|
||||
}
|
||||
|
||||
const geometry = readDisplacementGeometry(surface)
|
||||
if (!geometry || geometry.key === state.pendingGeometry) continue
|
||||
if (geometry.key === state.cachedGeometry && state.mapUrl.value === state.cachedMap) {
|
||||
// 取消草稿可能命中旧缓存,同时还有另一参数的解码;先使该异步结果失效。
|
||||
if (state.pendingGeometry) invalidateDisplacementMap(surface)
|
||||
state.shell?.setAttribute(state.readyAttribute, 'true')
|
||||
continue
|
||||
}
|
||||
invalidateDisplacementMap(surface)
|
||||
shouldSync = true
|
||||
}
|
||||
if (!shouldSync) return
|
||||
|
||||
resizeTimer = setTimeout(() => {
|
||||
resizeTimer = null
|
||||
syncDisplacementMaps()
|
||||
}, MAP_RESIZE_SETTLE_MS)
|
||||
}
|
||||
|
||||
function handleGeometryTransition(surface: NavigationSurface, event: TransitionEvent) {
|
||||
const state = getSurfaceState(surface)
|
||||
if (
|
||||
event.target !== state.element ||
|
||||
!/^(inset|top|left|right|width|height|inline-size|block-size|border.*radius)/u.test(event.propertyName)
|
||||
)
|
||||
return
|
||||
if (event.type === 'transitionrun') {
|
||||
state.geometryTransitions.add(event.propertyName)
|
||||
invalidateDisplacementMap(surface)
|
||||
} else {
|
||||
state.geometryTransitions.delete(event.propertyName)
|
||||
// transitionend/cancel 已给出稳定尺寸,无需再附加 resize 防抖等待。
|
||||
if (state.geometryTransitions.size === 0) {
|
||||
if (resizeTimer !== null) clearTimeout(resizeTimer)
|
||||
resizeTimer = null
|
||||
syncDisplacementMaps()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 草稿预览和取消复用同一有效参数源,旧异步解码不可覆盖新的滑杆值。
|
||||
watch(opticalResponse, scheduleDisplacementMapSync, { flush: 'sync' })
|
||||
|
||||
onMounted(() => {
|
||||
observedShell =
|
||||
document.querySelector<HTMLElement>('.layout-wrapper[data-glass-navigation-refraction="chromium"]') ??
|
||||
document.querySelector<HTMLElement>('.layout-wrapper[data-glass-navbar-refraction="chromium"]')
|
||||
if (!observedShell) return
|
||||
|
||||
navbarState.element = observedShell.querySelector<HTMLElement>('.layout-navbar')
|
||||
// Drawer 与桌面共用此元素;资格随断点判断,不能在挂载时永久排除 overlay 状态。
|
||||
sidebarState.element = observedShell.querySelector<HTMLElement>('.layout-vertical-nav')
|
||||
for (const surface of SURFACE_KEYS) getSurfaceState(surface).shell = observedShell
|
||||
|
||||
transparencyQuery = window.matchMedia('(prefers-reduced-transparency: reduce)')
|
||||
transparencyQuery.addEventListener('change', scheduleDisplacementMapSync)
|
||||
stateObserver = new MutationObserver(handleStateMutations)
|
||||
stateObserver.observe(document.documentElement, {
|
||||
attributes: true,
|
||||
attributeOldValue: true,
|
||||
attributeFilter: ['class', 'style', 'data-theme', 'data-glass-appearance', 'data-glass-quality'],
|
||||
})
|
||||
stateObserver.observe(observedShell, {
|
||||
attributes: true,
|
||||
attributeOldValue: true,
|
||||
attributeFilter: ['class', 'style'],
|
||||
})
|
||||
for (const surface of SURFACE_KEYS) {
|
||||
const state = getSurfaceState(surface)
|
||||
const element = state.element
|
||||
if (!element) continue
|
||||
stateObserver.observe(element, {
|
||||
attributes: true,
|
||||
attributeOldValue: true,
|
||||
attributeFilter: ['class', 'style'],
|
||||
})
|
||||
const transitionHandler: EventListener = event => handleGeometryTransition(surface, event as TransitionEvent)
|
||||
transitionHandlers[surface] = transitionHandler
|
||||
element.addEventListener('transitionrun', transitionHandler)
|
||||
element.addEventListener('transitionend', transitionHandler)
|
||||
element.addEventListener('transitioncancel', transitionHandler)
|
||||
}
|
||||
|
||||
scheduleDisplacementMapSync()
|
||||
if (typeof ResizeObserver === 'undefined') {
|
||||
window.addEventListener('resize', scheduleDisplacementMapSync, { passive: true })
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
resizeObserver = new ResizeObserver(scheduleDisplacementMapSync)
|
||||
for (const surface of SURFACE_KEYS) {
|
||||
const element = getSurfaceState(surface).element
|
||||
if (element) resizeObserver.observe(element)
|
||||
}
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
for (const surface of SURFACE_KEYS) {
|
||||
const state = getSurfaceState(surface)
|
||||
invalidateDisplacementMap(surface)
|
||||
state.geometryTransitions.clear()
|
||||
if (state.element && transitionHandlers[surface]) {
|
||||
state.element.removeEventListener('transitionrun', transitionHandlers[surface])
|
||||
state.element.removeEventListener('transitionend', transitionHandlers[surface])
|
||||
state.element.removeEventListener('transitioncancel', transitionHandlers[surface])
|
||||
}
|
||||
transitionHandlers[surface] = null
|
||||
state.shell?.removeAttribute(state.readyAttribute)
|
||||
state.element = null
|
||||
state.shell = null
|
||||
}
|
||||
if (resizeTimer !== null) clearTimeout(resizeTimer)
|
||||
resizeTimer = null
|
||||
resizeObserver?.disconnect()
|
||||
resizeObserver = null
|
||||
stateObserver?.disconnect()
|
||||
stateObserver = null
|
||||
transparencyQuery?.removeEventListener('change', scheduleDisplacementMapSync)
|
||||
transparencyQuery = null
|
||||
window.removeEventListener('resize', scheduleDisplacementMapSync)
|
||||
observedShell = null
|
||||
})
|
||||
</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="0%"
|
||||
y="0%"
|
||||
width="100%"
|
||||
height="100%"
|
||||
color-interpolation-filters="sRGB"
|
||||
>
|
||||
<feImage
|
||||
x="0"
|
||||
y="0"
|
||||
:width="navbarMapSize.width"
|
||||
:height="navbarMapSize.height"
|
||||
preserveAspectRatio="none"
|
||||
:href="navbarMapUrl"
|
||||
result="map"
|
||||
/>
|
||||
<feDisplacementMap in="SourceGraphic" in2="map" xChannelSelector="R" yChannelSelector="B" scale="-22" />
|
||||
</filter>
|
||||
|
||||
<filter
|
||||
id="glass-navbar-live-refraction-high"
|
||||
x="0%"
|
||||
y="0%"
|
||||
width="100%"
|
||||
height="100%"
|
||||
color-interpolation-filters="sRGB"
|
||||
>
|
||||
<feImage
|
||||
x="0"
|
||||
y="0"
|
||||
:width="navbarMapSize.width"
|
||||
:height="navbarMapSize.height"
|
||||
preserveAspectRatio="none"
|
||||
:href="navbarMapUrl"
|
||||
result="map"
|
||||
/>
|
||||
<feDisplacementMap in="SourceGraphic" in2="map" xChannelSelector="R" yChannelSelector="B" scale="-34" />
|
||||
</filter>
|
||||
|
||||
<filter
|
||||
id="glass-sidebar-live-refraction-balanced"
|
||||
x="0%"
|
||||
y="0%"
|
||||
width="100%"
|
||||
height="100%"
|
||||
color-interpolation-filters="sRGB"
|
||||
>
|
||||
<feImage
|
||||
x="0"
|
||||
y="0"
|
||||
:width="sidebarMapSize.width"
|
||||
:height="sidebarMapSize.height"
|
||||
preserveAspectRatio="none"
|
||||
:href="sidebarMapUrl"
|
||||
result="map"
|
||||
/>
|
||||
<feDisplacementMap in="SourceGraphic" in2="map" xChannelSelector="R" yChannelSelector="B" scale="-22" />
|
||||
</filter>
|
||||
|
||||
<filter
|
||||
id="glass-sidebar-live-refraction-high"
|
||||
x="0%"
|
||||
y="0%"
|
||||
width="100%"
|
||||
height="100%"
|
||||
color-interpolation-filters="sRGB"
|
||||
>
|
||||
<feImage
|
||||
x="0"
|
||||
y="0"
|
||||
:width="sidebarMapSize.width"
|
||||
:height="sidebarMapSize.height"
|
||||
preserveAspectRatio="none"
|
||||
:href="sidebarMapUrl"
|
||||
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>
|
||||
@@ -1,5 +1,6 @@
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { flushPromises, mount } from '@vue/test-utils'
|
||||
import { nextTick } from 'vue'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import GlassFixedShellBackplate from '@/components/theme/GlassFixedShellBackplate.vue'
|
||||
import type { GlassFixedShellBackplateLayer } from '@/composables/useGlassFixedShellBackplate'
|
||||
|
||||
@@ -26,9 +27,146 @@ const initialLayers: readonly GlassFixedShellBackplateLayer[] = [
|
||||
},
|
||||
]
|
||||
|
||||
interface TestRect {
|
||||
bottom: number
|
||||
height: number
|
||||
left: number
|
||||
right: number
|
||||
top: number
|
||||
width: number
|
||||
toJSON: () => Record<string, never>
|
||||
}
|
||||
|
||||
interface BackplateMountOptions {
|
||||
[key: string]: unknown
|
||||
attachTo?: Element
|
||||
props: {
|
||||
isOverlayNav: boolean
|
||||
isOverlayNavActive: boolean
|
||||
layers: readonly GlassFixedShellBackplateLayer[]
|
||||
transitionDurationMs: number
|
||||
}
|
||||
}
|
||||
|
||||
const mountedWrappers: Array<ReturnType<typeof mount>> = []
|
||||
let resizeCallback: ResizeObserverCallback | undefined
|
||||
let resizeDisconnect: ReturnType<typeof vi.fn> | undefined
|
||||
let backplateRect: TestRect
|
||||
let sidebarRect: TestRect
|
||||
let navbarRect: TestRect
|
||||
|
||||
function createRect(left: number, top: number, width: number, height: number): TestRect {
|
||||
return {
|
||||
bottom: top + height,
|
||||
height,
|
||||
left,
|
||||
right: left + width,
|
||||
top,
|
||||
toJSON: () => ({}),
|
||||
width,
|
||||
}
|
||||
}
|
||||
|
||||
function mountBackplate(options: BackplateMountOptions) {
|
||||
const wrapper = mount(GlassFixedShellBackplate, options)
|
||||
mountedWrappers.push(wrapper)
|
||||
return wrapper
|
||||
}
|
||||
|
||||
function mountConnectedShell(horizontal = false) {
|
||||
const shell = document.createElement('div')
|
||||
shell.className = `layout-wrapper${horizontal ? ' layout-horizontal-nav-active' : ''}`
|
||||
shell.dataset.shellMode = 'desktop'
|
||||
shell.dataset.shellNavbarAttachment = horizontal ? 'theme-qualified' : 'connected'
|
||||
|
||||
const sidebar = document.createElement('aside')
|
||||
sidebar.className = 'layout-vertical-nav'
|
||||
const navbar = document.createElement('header')
|
||||
navbar.className = 'layout-navbar'
|
||||
shell.append(sidebar, navbar)
|
||||
document.body.append(shell)
|
||||
|
||||
return {
|
||||
navbar,
|
||||
shell,
|
||||
sidebar,
|
||||
wrapper: mountBackplate({
|
||||
attachTo: shell,
|
||||
props: {
|
||||
isOverlayNav: false,
|
||||
isOverlayNavActive: false,
|
||||
layers: initialLayers,
|
||||
transitionDurationMs: 1500,
|
||||
},
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
async function settleGeometry() {
|
||||
await nextTick()
|
||||
await flushPromises()
|
||||
await nextTick()
|
||||
await Promise.resolve()
|
||||
}
|
||||
|
||||
describe('GlassFixedShellBackplate', () => {
|
||||
beforeEach(() => {
|
||||
resizeCallback = undefined
|
||||
resizeDisconnect = vi.fn()
|
||||
backplateRect = createRect(5, 3, 1185, 790)
|
||||
sidebarRect = createRect(13, 11, 252, 774)
|
||||
navbarRect = createRect(273, 11, 909, 72)
|
||||
|
||||
vi.stubGlobal(
|
||||
'ResizeObserver',
|
||||
class {
|
||||
constructor(callback: ResizeObserverCallback) {
|
||||
resizeCallback = callback
|
||||
}
|
||||
|
||||
observe() {}
|
||||
|
||||
unobserve() {}
|
||||
|
||||
disconnect() {
|
||||
resizeDisconnect?.()
|
||||
}
|
||||
},
|
||||
)
|
||||
vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockImplementation(function (this: HTMLElement) {
|
||||
if (this.classList.contains('glass-fixed-shell-backplate--main')) return backplateRect as DOMRect
|
||||
if (this.classList.contains('layout-vertical-nav')) return sidebarRect as DOMRect
|
||||
if (this.classList.contains('layout-navbar')) return navbarRect as DOMRect
|
||||
return createRect(0, 0, 0, 0) as DOMRect
|
||||
})
|
||||
vi.spyOn(window, 'getComputedStyle').mockImplementation(
|
||||
element =>
|
||||
({
|
||||
borderBottomLeftRadius: element instanceof HTMLElement ? '16px' : '0px',
|
||||
borderBottomRightRadius: element instanceof HTMLElement ? '16px' : '0px',
|
||||
borderEndEndRadius: element instanceof HTMLElement ? '16px' : '0px',
|
||||
borderEndStartRadius: element instanceof HTMLElement ? '16px' : '0px',
|
||||
borderRadius: element instanceof HTMLElement ? '16px' : '0px',
|
||||
borderStartEndRadius: element instanceof HTMLElement ? '16px' : '0px',
|
||||
borderStartStartRadius: element instanceof HTMLElement ? '16px' : '0px',
|
||||
borderTopLeftRadius: element instanceof HTMLElement ? '16px' : '0px',
|
||||
borderTopRightRadius: element instanceof HTMLElement ? '16px' : '0px',
|
||||
getPropertyValue: (property: string) => (property.includes('radius') ? '16px' : ''),
|
||||
}) as unknown as CSSStyleDeclaration,
|
||||
)
|
||||
document.documentElement.dataset.theme = 'glass'
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
for (const wrapper of mountedWrappers.splice(0)) wrapper.unmount()
|
||||
document.querySelectorAll('.layout-wrapper').forEach(element => element.remove())
|
||||
document.documentElement.removeAttribute('data-theme')
|
||||
vi.restoreAllMocks()
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
it('renders the App-owned slots once for the shared desktop shell', () => {
|
||||
const wrapper = mount(GlassFixedShellBackplate, {
|
||||
const wrapper = mountBackplate({
|
||||
props: {
|
||||
isOverlayNav: false,
|
||||
isOverlayNavActive: false,
|
||||
@@ -51,7 +189,7 @@ describe('GlassFixedShellBackplate', () => {
|
||||
})
|
||||
|
||||
it('preserves slot nodes while their active and previous roles swap', async () => {
|
||||
const wrapper = mount(GlassFixedShellBackplate, {
|
||||
const wrapper = mountBackplate({
|
||||
props: {
|
||||
isOverlayNav: false,
|
||||
isOverlayNavActive: false,
|
||||
@@ -76,7 +214,7 @@ describe('GlassFixedShellBackplate', () => {
|
||||
})
|
||||
|
||||
it('adds a separately clipped surface only for mobile overlay navigation', () => {
|
||||
const wrapper = mount(GlassFixedShellBackplate, {
|
||||
const wrapper = mountBackplate({
|
||||
props: {
|
||||
isOverlayNav: true,
|
||||
isOverlayNavActive: true,
|
||||
@@ -90,4 +228,71 @@ describe('GlassFixedShellBackplate', () => {
|
||||
expect(overlay.classes()).toContain('is-visible')
|
||||
expect(overlay.findAll('[data-backplate-slot]')).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('maps both connected surfaces to backplate-relative objectBoundingBox geometry', async () => {
|
||||
const { wrapper } = mountConnectedShell()
|
||||
await settleGeometry()
|
||||
|
||||
const main = wrapper.get('[data-backplate-surface="main"]')
|
||||
const mainElement = main.element as HTMLElement
|
||||
const clipPath = wrapper.get('clipPath')
|
||||
const rects = clipPath.findAll('rect')
|
||||
|
||||
expect(clipPath.attributes('clipPathUnits')).toBe('objectBoundingBox')
|
||||
expect(rects).toHaveLength(2)
|
||||
expect(mainElement.style.clipPath).toMatch(/^url\(#glass-fixed-shell-clip-/u)
|
||||
expect(Number(rects[0].attributes('x'))).toBeCloseTo(8 / 1185, 8)
|
||||
expect(Number(rects[0].attributes('y'))).toBeCloseTo(8 / 790, 8)
|
||||
expect(Number(rects[0].attributes('width'))).toBeCloseTo(252 / 1185, 8)
|
||||
expect(Number(rects[0].attributes('height'))).toBeCloseTo(774 / 790, 8)
|
||||
expect(Number(rects[0].attributes('rx'))).toBeCloseTo(16 / 1185, 8)
|
||||
expect(Number(rects[0].attributes('ry'))).toBeCloseTo(16 / 790, 8)
|
||||
expect(Number(rects[1].attributes('x'))).toBeCloseTo(268 / 1185, 8)
|
||||
expect(Number(rects[1].attributes('y'))).toBeCloseTo(8 / 790, 8)
|
||||
expect(Number(rects[1].attributes('width'))).toBeCloseTo(909 / 1185, 8)
|
||||
expect(Number(rects[1].attributes('height'))).toBeCloseTo(72 / 790, 8)
|
||||
expect(Number(rects[1].attributes('rx'))).toBeCloseTo(16 / 1185, 8)
|
||||
expect(Number(rects[1].attributes('ry'))).toBeCloseTo(16 / 790, 8)
|
||||
expect(wrapper.findAll('[data-backplate-surface="main"]')).toHaveLength(1)
|
||||
expect(wrapper.findAll('[data-backplate-slot]')).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('does not activate the connected clip for horizontal navigation', async () => {
|
||||
const { wrapper } = mountConnectedShell(true)
|
||||
await settleGeometry()
|
||||
|
||||
expect((wrapper.get('[data-backplate-surface="main"]').element as HTMLElement).style.clipPath).toBe('')
|
||||
expect(wrapper.findAll('clipPath rect')).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('refreshes dimensions and disconnects the resize observer on unmount', async () => {
|
||||
const { wrapper } = mountConnectedShell()
|
||||
await settleGeometry()
|
||||
|
||||
backplateRect = createRect(5, 3, 1180, 790)
|
||||
sidebarRect = createRect(13, 11, 60, 774)
|
||||
navbarRect = createRect(273, 11, 904, 96)
|
||||
resizeCallback?.([], {} as ResizeObserver)
|
||||
await settleGeometry()
|
||||
|
||||
const rects = wrapper.findAll('clipPath rect')
|
||||
expect(Number(rects[0].attributes('width'))).toBeCloseTo(60 / 1180, 8)
|
||||
expect(Number(rects[1].attributes('x'))).toBeCloseTo(268 / 1180, 8)
|
||||
expect(Number(rects[1].attributes('height'))).toBeCloseTo(96 / 790, 8)
|
||||
|
||||
wrapper.unmount()
|
||||
expect(resizeDisconnect).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('uses window resize when ResizeObserver is unavailable', async () => {
|
||||
vi.stubGlobal('ResizeObserver', undefined)
|
||||
const { wrapper } = mountConnectedShell()
|
||||
await settleGeometry()
|
||||
|
||||
sidebarRect = createRect(13, 11, 60, 774)
|
||||
window.dispatchEvent(new Event('resize'))
|
||||
await settleGeometry()
|
||||
|
||||
expect(Number(wrapper.find('clipPath rect').attributes('width'))).toBeCloseTo(60 / 1185, 8)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -0,0 +1,470 @@
|
||||
import { mount, flushPromises } from '@vue/test-utils'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import GlassNavbarRefractionDefs from '../GlassNavbarRefractionDefs.vue'
|
||||
import { createGlassNavbarDisplacementMap } from '@/utils/glassNavbarRefraction'
|
||||
import { ref } from 'vue'
|
||||
|
||||
vi.mock('@/utils/glassNavbarRefraction', async importOriginal => ({
|
||||
...(await importOriginal<typeof import('@/utils/glassNavbarRefraction')>()),
|
||||
createGlassNavbarDisplacementMap: vi.fn(() => 'data:image/png;base64,test'),
|
||||
NEUTRAL_GLASS_NAVBAR_DISPLACEMENT_MAP: 'neutral',
|
||||
}))
|
||||
const effectiveSettings = ref({ glassDeformationStrength: 48, glassTranslationStrength: 48 })
|
||||
vi.mock('@/composables/useThemeCustomizer', () => ({ useEffectiveGlassSettings: () => effectiveSettings }))
|
||||
|
||||
describe('GlassNavbarRefractionDefs', () => {
|
||||
let shell: HTMLDivElement
|
||||
let navbar: HTMLElement
|
||||
let resize: ResizeObserverCallback | undefined
|
||||
let wrapper: ReturnType<typeof mount> | undefined
|
||||
let width: number
|
||||
let sidebarWidth: number
|
||||
let radius: number
|
||||
let sidebar: HTMLElement | undefined
|
||||
let transparencyReduced: boolean
|
||||
let transparencyChange: ((event: MediaQueryListEvent) => void) | undefined
|
||||
let decodePending: Array<{ resolve: () => void; reject: (reason?: unknown) => void }>
|
||||
const disconnect = vi.fn()
|
||||
const observe = vi.fn()
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers()
|
||||
vi.clearAllMocks()
|
||||
effectiveSettings.value = { glassDeformationStrength: 48, glassTranslationStrength: 48 }
|
||||
width = 1423
|
||||
sidebarWidth = 260
|
||||
radius = 16
|
||||
transparencyReduced = false
|
||||
transparencyChange = undefined
|
||||
decodePending = []
|
||||
sidebar = undefined
|
||||
shell = document.createElement('div')
|
||||
shell.className =
|
||||
'layout-wrapper layout-horizontal-nav-active layout-navbar-floating-eligible layout-navbar-away-from-top'
|
||||
shell.dataset.glassNavbarRefraction = 'chromium'
|
||||
shell.innerHTML = '<header class="layout-navbar"></header>'
|
||||
document.body.append(shell)
|
||||
navbar = shell.querySelector('.layout-navbar') as HTMLElement
|
||||
Object.assign(document.documentElement.dataset, { theme: 'glass', glassAppearance: 'clear', glassQuality: 'high' })
|
||||
vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockImplementation(function (this: HTMLElement) {
|
||||
const isSidebar = this.classList.contains('layout-vertical-nav')
|
||||
const elementWidth = isSidebar ? sidebarWidth : width
|
||||
const elementHeight = isSidebar ? 800 : 64
|
||||
|
||||
return {
|
||||
x: 16,
|
||||
y: 16,
|
||||
left: 16,
|
||||
top: 16,
|
||||
width: elementWidth,
|
||||
height: elementHeight,
|
||||
right: elementWidth + 16,
|
||||
bottom: elementHeight + 16,
|
||||
toJSON: () => ({}),
|
||||
}
|
||||
})
|
||||
vi.spyOn(window, 'getComputedStyle').mockImplementation(
|
||||
element =>
|
||||
({
|
||||
borderStartStartRadius: element.classList.contains('layout-vertical-nav') ? '0px' : `${radius}px`,
|
||||
getPropertyValue: () => '1rem',
|
||||
}) as unknown as CSSStyleDeclaration,
|
||||
)
|
||||
vi.stubGlobal(
|
||||
'ResizeObserver',
|
||||
class {
|
||||
constructor(callback: ResizeObserverCallback) {
|
||||
resize = callback
|
||||
}
|
||||
observe = observe
|
||||
disconnect = disconnect
|
||||
},
|
||||
)
|
||||
vi.stubGlobal('matchMedia', () => ({
|
||||
get matches() {
|
||||
return transparencyReduced
|
||||
},
|
||||
addEventListener: vi.fn((_event: string, listener: (event: MediaQueryListEvent) => void) => {
|
||||
transparencyChange = listener
|
||||
}),
|
||||
removeEventListener: vi.fn(),
|
||||
}))
|
||||
vi.stubGlobal(
|
||||
'Image',
|
||||
class {
|
||||
src = ''
|
||||
decode = vi.fn(
|
||||
() =>
|
||||
new Promise<void>((resolve, reject) => {
|
||||
decodePending.push({ resolve, reject })
|
||||
}),
|
||||
)
|
||||
},
|
||||
)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
wrapper?.unmount()
|
||||
wrapper = undefined
|
||||
shell.remove()
|
||||
vi.restoreAllMocks()
|
||||
vi.unstubAllGlobals()
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
async function settle() {
|
||||
await vi.advanceTimersByTimeAsync(65)
|
||||
completePendingDecode()
|
||||
await flushPromises()
|
||||
}
|
||||
|
||||
function expectReadyForWidth(expectedWidth: number) {
|
||||
expect(shell.dataset.glassNavbarRefractionReady).toBe('true')
|
||||
expect(wrapper?.get('feImage').attributes('width')).toBe(String(expectedWidth))
|
||||
}
|
||||
|
||||
function expectReadyForSidebar(expectedWidth: number) {
|
||||
expect(shell.dataset.glassSidebarRefractionReady).toBe('true')
|
||||
expect(wrapper?.findAll('feImage')[2].attributes('width')).toBe(String(expectedWidth))
|
||||
expect(wrapper?.findAll('feImage')[2].attributes('height')).toBe('800')
|
||||
}
|
||||
|
||||
function completePendingDecode() {
|
||||
for (const pending of decodePending.splice(0)) pending.resolve()
|
||||
}
|
||||
|
||||
function dispatchTransition(type: 'transitionrun' | 'transitionend' | 'transitioncancel', propertyName: string) {
|
||||
const event = new Event(type, { bubbles: true }) as TransitionEvent
|
||||
Object.defineProperty(event, 'propertyName', { value: propertyName })
|
||||
navbar.dispatchEvent(event)
|
||||
}
|
||||
|
||||
function mountWithSidebar(overlay = false) {
|
||||
sidebar = document.createElement('aside')
|
||||
sidebar.className = `layout-vertical-nav${overlay ? ' overlay-nav' : ''}`
|
||||
shell.prepend(sidebar)
|
||||
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')
|
||||
await settle()
|
||||
expect(createGlassNavbarDisplacementMap).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ width: 1423, height: 64, radius: 16 }),
|
||||
)
|
||||
expectReadyForWidth(1423)
|
||||
})
|
||||
|
||||
it('keeps independent geometry caches when switching between horizontal and vertical navigation', async () => {
|
||||
mountWithSidebar()
|
||||
await settle()
|
||||
expect(createGlassNavbarDisplacementMap).toHaveBeenCalledTimes(1)
|
||||
expectReadyForWidth(1423)
|
||||
expect(shell.dataset.glassSidebarRefractionReady).toBe('false')
|
||||
shell.className = 'layout-wrapper'
|
||||
await flushPromises()
|
||||
await settle()
|
||||
expect(createGlassNavbarDisplacementMap).toHaveBeenCalledTimes(2)
|
||||
expect(createGlassNavbarDisplacementMap).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ width: 1423, height: 64, radius: 16 }),
|
||||
)
|
||||
expect(createGlassNavbarDisplacementMap).toHaveBeenLastCalledWith(
|
||||
expect.objectContaining({ width: 260, height: 800, radius: 0 }),
|
||||
)
|
||||
expect(shell.dataset.glassNavbarRefractionReady).toBe('false')
|
||||
expectReadyForSidebar(260)
|
||||
|
||||
shell.className =
|
||||
'layout-wrapper layout-horizontal-nav-active layout-navbar-floating-eligible layout-navbar-away-from-top'
|
||||
await flushPromises()
|
||||
await settle()
|
||||
expectReadyForWidth(1423)
|
||||
expect(shell.dataset.glassSidebarRefractionReady).toBe('false')
|
||||
expect(createGlassNavbarDisplacementMap).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('rebuilds only the sidebar map when its collapsed width changes', async () => {
|
||||
shell.className = 'layout-wrapper'
|
||||
mountWithSidebar()
|
||||
await settle()
|
||||
const initialCallCount = vi.mocked(createGlassNavbarDisplacementMap).mock.calls.length
|
||||
|
||||
sidebarWidth = 80
|
||||
resize?.([], {} as ResizeObserver)
|
||||
await settle()
|
||||
|
||||
expect(createGlassNavbarDisplacementMap).toHaveBeenCalledTimes(initialCallCount + 1)
|
||||
expect(createGlassNavbarDisplacementMap).toHaveBeenLastCalledWith(
|
||||
expect.objectContaining({ width: 80, height: 800, radius: 0 }),
|
||||
)
|
||||
expect(shell.dataset.glassNavbarRefractionReady).toBe('false')
|
||||
expectReadyForSidebar(80)
|
||||
})
|
||||
|
||||
it('does not generate a live map for a mobile Drawer surface', async () => {
|
||||
shell.className = 'layout-wrapper layout-overlay-nav'
|
||||
mountWithSidebar(true)
|
||||
await settle()
|
||||
|
||||
expect(createGlassNavbarDisplacementMap).not.toHaveBeenCalled()
|
||||
expect(shell.dataset.glassNavbarRefractionReady).toBe('false')
|
||||
expect(shell.dataset.glassSidebarRefractionReady).toBe('false')
|
||||
})
|
||||
|
||||
it('activates the existing sidebar when a Drawer viewport returns to desktop', async () => {
|
||||
shell.className = 'layout-wrapper layout-overlay-nav'
|
||||
mountWithSidebar(true)
|
||||
await settle()
|
||||
shell.classList.remove('layout-overlay-nav')
|
||||
sidebar?.classList.remove('overlay-nav')
|
||||
await flushPromises()
|
||||
await settle()
|
||||
expectReadyForSidebar(260)
|
||||
expect(createGlassNavbarDisplacementMap).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('does not strand a sidebar update when an unrelated fixed navbar transition ends', async () => {
|
||||
shell.className = 'layout-wrapper'
|
||||
mountWithSidebar()
|
||||
await settle()
|
||||
dispatchTransition('transitionrun', 'height')
|
||||
effectiveSettings.value = { glassDeformationStrength: 99, glassTranslationStrength: 99 }
|
||||
dispatchTransition('transitionend', 'height')
|
||||
completePendingDecode()
|
||||
await flushPromises()
|
||||
expectReadyForSidebar(260)
|
||||
expect(createGlassNavbarDisplacementMap).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('keeps unchanged geometry ready but disables old sampling when the size changes', async () => {
|
||||
wrapper = mount(GlassNavbarRefractionDefs)
|
||||
await settle()
|
||||
resize?.([], {} as ResizeObserver)
|
||||
expect(shell.dataset.glassNavbarRefractionReady).toBe('true')
|
||||
await settle()
|
||||
expect(createGlassNavbarDisplacementMap).toHaveBeenCalledTimes(1)
|
||||
width = 1200
|
||||
resize?.([], {} as ResizeObserver)
|
||||
expect(shell.dataset.glassNavbarRefractionReady).toBe('false')
|
||||
await settle()
|
||||
expect(createGlassNavbarDisplacementMap).toHaveBeenLastCalledWith(
|
||||
expect.objectContaining({ width: 1200, height: 64, radius: 16 }),
|
||||
)
|
||||
expectReadyForWidth(1200)
|
||||
})
|
||||
|
||||
it('restores a cached map immediately when the final geometry transition ends', async () => {
|
||||
wrapper = mount(GlassNavbarRefractionDefs)
|
||||
await settle()
|
||||
dispatchTransition('transitionrun', 'inset-inline-start')
|
||||
dispatchTransition('transitionrun', 'border-radius')
|
||||
dispatchTransition('transitionend', 'inset-inline-start')
|
||||
await flushPromises()
|
||||
expect(shell.dataset.glassNavbarRefractionReady).toBe('false')
|
||||
dispatchTransition('transitionend', 'border-radius')
|
||||
await flushPromises()
|
||||
expectReadyForWidth(1423)
|
||||
expect(createGlassNavbarDisplacementMap).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('does not cancel a pending decode on a duplicate resize notification', async () => {
|
||||
wrapper = mount(GlassNavbarRefractionDefs)
|
||||
await vi.advanceTimersByTimeAsync(65)
|
||||
resize?.([], {} as ResizeObserver)
|
||||
completePendingDecode()
|
||||
await flushPromises()
|
||||
expectReadyForWidth(1423)
|
||||
expect(createGlassNavbarDisplacementMap).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('rejects a late draft decode when cancellation already restored the cached map', async () => {
|
||||
vi.mocked(createGlassNavbarDisplacementMap)
|
||||
.mockReturnValueOnce('data:image/png;base64,saved')
|
||||
.mockReturnValueOnce('data:image/png;base64,draft')
|
||||
wrapper = mount(GlassNavbarRefractionDefs)
|
||||
await settle()
|
||||
effectiveSettings.value = { glassDeformationStrength: 99, glassTranslationStrength: 99 }
|
||||
await vi.advanceTimersByTimeAsync(65)
|
||||
expect(createGlassNavbarDisplacementMap).toHaveBeenCalledTimes(2)
|
||||
effectiveSettings.value = { glassDeformationStrength: 48, glassTranslationStrength: 48 }
|
||||
expectReadyForWidth(1423)
|
||||
completePendingDecode()
|
||||
await flushPromises()
|
||||
expect(wrapper.get('feImage').attributes('href')).toBe('data:image/png;base64,saved')
|
||||
})
|
||||
|
||||
it('does not generate a map in CSS quality', async () => {
|
||||
document.documentElement.dataset.glassQuality = 'css'
|
||||
wrapper = mount(GlassNavbarRefractionDefs)
|
||||
await settle()
|
||||
expect(createGlassNavbarDisplacementMap).not.toHaveBeenCalled()
|
||||
expect(shell.dataset.glassNavbarRefractionReady).toBe('false')
|
||||
})
|
||||
|
||||
it('rebuilds on effective preview parameters and restores the cancelled draft', async () => {
|
||||
wrapper = mount(GlassNavbarRefractionDefs)
|
||||
await settle()
|
||||
effectiveSettings.value = { glassDeformationStrength: 0, glassTranslationStrength: 100 }
|
||||
expect(shell.dataset.glassNavbarRefractionReady).toBe('false')
|
||||
await settle()
|
||||
expect(createGlassNavbarDisplacementMap).toHaveBeenLastCalledWith(
|
||||
expect.objectContaining({ optics: { horizontalRatio: 0, verticalRatio: 0, translationPx: 17 } }),
|
||||
)
|
||||
effectiveSettings.value = { glassDeformationStrength: 48, glassTranslationStrength: 48 }
|
||||
await settle()
|
||||
expect(createGlassNavbarDisplacementMap).toHaveBeenLastCalledWith(
|
||||
expect.objectContaining({ optics: expect.objectContaining({ translationPx: expect.closeTo(1.880064) }) }),
|
||||
)
|
||||
})
|
||||
|
||||
it('does not activate a pending map after switching to CSS quality', async () => {
|
||||
wrapper = mount(GlassNavbarRefractionDefs)
|
||||
await vi.advanceTimersByTimeAsync(65)
|
||||
expect(createGlassNavbarDisplacementMap).toHaveBeenCalledTimes(1)
|
||||
document.documentElement.dataset.glassQuality = 'css'
|
||||
await flushPromises()
|
||||
completePendingDecode()
|
||||
await flushPromises()
|
||||
expect(shell.dataset.glassNavbarRefractionReady).toBe('false')
|
||||
})
|
||||
|
||||
it('does not generate or activate a map while reduced transparency is enabled', async () => {
|
||||
transparencyReduced = true
|
||||
wrapper = mount(GlassNavbarRefractionDefs)
|
||||
await settle()
|
||||
expect(createGlassNavbarDisplacementMap).not.toHaveBeenCalled()
|
||||
expect(shell.dataset.glassNavbarRefractionReady).toBe('false')
|
||||
|
||||
transparencyReduced = false
|
||||
transparencyChange?.({ matches: false } as MediaQueryListEvent)
|
||||
await settle()
|
||||
expect(createGlassNavbarDisplacementMap).toHaveBeenCalledTimes(1)
|
||||
expectReadyForWidth(1423)
|
||||
})
|
||||
|
||||
it('regenerates after a geometry transition ends or is cancelled', async () => {
|
||||
wrapper = mount(GlassNavbarRefractionDefs)
|
||||
await settle()
|
||||
|
||||
width = 1200
|
||||
dispatchTransition('transitionrun', 'width')
|
||||
expect(shell.dataset.glassNavbarRefractionReady).toBe('false')
|
||||
dispatchTransition('transitionend', 'width')
|
||||
await settle()
|
||||
expect(createGlassNavbarDisplacementMap).toHaveBeenLastCalledWith(
|
||||
expect.objectContaining({ width: 1200, height: 64, radius: 16 }),
|
||||
)
|
||||
expectReadyForWidth(1200)
|
||||
|
||||
radius = 20
|
||||
dispatchTransition('transitionrun', 'border-radius')
|
||||
dispatchTransition('transitioncancel', 'border-radius')
|
||||
await settle()
|
||||
expect(createGlassNavbarDisplacementMap).toHaveBeenLastCalledWith(
|
||||
expect.objectContaining({ width: 1200, height: 64, radius: 20 }),
|
||||
)
|
||||
expectReadyForWidth(1200)
|
||||
})
|
||||
|
||||
it('waits for every geometry transition before restoring the map', async () => {
|
||||
wrapper = mount(GlassNavbarRefractionDefs)
|
||||
await settle()
|
||||
|
||||
dispatchTransition('transitionrun', 'width')
|
||||
dispatchTransition('transitionrun', 'border-radius')
|
||||
dispatchTransition('transitionend', 'width')
|
||||
await vi.advanceTimersByTimeAsync(65)
|
||||
expect(shell.dataset.glassNavbarRefractionReady).toBe('false')
|
||||
|
||||
dispatchTransition('transitioncancel', 'border-radius')
|
||||
await settle()
|
||||
expectReadyForWidth(1423)
|
||||
})
|
||||
|
||||
it('only regenerates for radius or observed theme size changes', async () => {
|
||||
wrapper = mount(GlassNavbarRefractionDefs)
|
||||
await settle()
|
||||
expect(createGlassNavbarDisplacementMap).toHaveBeenCalledTimes(1)
|
||||
|
||||
shell.style.setProperty('--shell-floating-navbar-scale-x', '0.9')
|
||||
await flushPromises()
|
||||
await settle()
|
||||
expect(createGlassNavbarDisplacementMap).toHaveBeenCalledTimes(1)
|
||||
|
||||
radius = 20
|
||||
shell.style.setProperty('--shell-floating-navbar-radius', '20px')
|
||||
await flushPromises()
|
||||
await settle()
|
||||
expect(createGlassNavbarDisplacementMap).toHaveBeenLastCalledWith(
|
||||
expect.objectContaining({ width: 1423, height: 64, radius: 20 }),
|
||||
)
|
||||
})
|
||||
|
||||
it('does not retry a failed geometry in a feedback loop', async () => {
|
||||
wrapper = mount(GlassNavbarRefractionDefs)
|
||||
await vi.advanceTimersByTimeAsync(65)
|
||||
const [pending] = decodePending.splice(0)
|
||||
pending.reject(new Error('decode failed'))
|
||||
await flushPromises()
|
||||
expect(createGlassNavbarDisplacementMap).toHaveBeenCalledTimes(1)
|
||||
|
||||
resize?.([], {} as ResizeObserver)
|
||||
await settle()
|
||||
resize?.([], {} as ResizeObserver)
|
||||
await settle()
|
||||
expect(createGlassNavbarDisplacementMap).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('uses the window resize fallback and removes it on unmount', async () => {
|
||||
vi.stubGlobal('ResizeObserver', undefined)
|
||||
wrapper = mount(GlassNavbarRefractionDefs)
|
||||
await settle()
|
||||
expect(observe).not.toHaveBeenCalled()
|
||||
|
||||
width = 1200
|
||||
window.dispatchEvent(new Event('resize'))
|
||||
await settle()
|
||||
expect(createGlassNavbarDisplacementMap).toHaveBeenLastCalledWith(
|
||||
expect.objectContaining({ width: 1200, height: 64, radius: 16 }),
|
||||
)
|
||||
|
||||
wrapper.unmount()
|
||||
wrapper = undefined
|
||||
width = 1100
|
||||
window.dispatchEvent(new Event('resize'))
|
||||
await settle()
|
||||
expect(shell.hasAttribute('data-glass-navbar-refraction-ready')).toBe(false)
|
||||
})
|
||||
|
||||
it('drops pending work on unmount', async () => {
|
||||
wrapper = mount(GlassNavbarRefractionDefs)
|
||||
await vi.advanceTimersByTimeAsync(65)
|
||||
expect(createGlassNavbarDisplacementMap).toHaveBeenCalledTimes(1)
|
||||
wrapper.unmount()
|
||||
wrapper = undefined
|
||||
completePendingDecode()
|
||||
await flushPromises()
|
||||
expect(shell.hasAttribute('data-glass-navbar-refraction-ready')).toBe(false)
|
||||
expect(disconnect).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
File diff suppressed because it is too large
Load Diff
@@ -707,27 +707,34 @@ vec3 toneMapWallpaper(vec3 color, vec2 uv, float wallpaperExposure) {
|
||||
vec3 sampleWallpaper(vec2 uv) {
|
||||
vec2 viewportUv = vec2(0.5) + (uv - vec2(0.5)) / max(uCoverScale, vec2(0.0001));
|
||||
vec2 previousUv = vec2(0.5) + (viewportUv - vec2(0.5)) * uPreviousCoverScale;
|
||||
vec3 previous;
|
||||
vec3 current;
|
||||
vec3 previous = vec3(0.0);
|
||||
vec3 current = vec3(0.0);
|
||||
// 稳定端点只读取参与输出的纹理;过渡中仍按各自曝光映射后混合。
|
||||
bool needsPrevious = uTextureMix < 0.999;
|
||||
bool needsCurrent = uTextureMix > 0.001;
|
||||
if (uAppearance > 1.5 && uHasFrostedTexture > 0.5) {
|
||||
float frostLod = (1.0 - uFrostDetailLevel) * 6.0;
|
||||
// 低分辨率预滤已经扩大了每个 texel 的原图 footprint,LOD 只追加当前纹理内的低通层级。
|
||||
float frostGradientScale = exp2(frostLod);
|
||||
if (needsPrevious) {
|
||||
previous = texture2DGradEXT(
|
||||
uPreviousFrostedTexture,
|
||||
previousUv,
|
||||
dFdx(previousUv) * frostGradientScale,
|
||||
dFdy(previousUv) * frostGradientScale
|
||||
).rgb;
|
||||
}
|
||||
if (needsCurrent) {
|
||||
current = texture2DGradEXT(
|
||||
uFrostedTexture,
|
||||
uv,
|
||||
dFdx(uv) * frostGradientScale,
|
||||
dFdy(uv) * frostGradientScale
|
||||
).rgb;
|
||||
}
|
||||
} else {
|
||||
previous = texture2D(uPreviousTexture, previousUv).rgb;
|
||||
current = texture2D(uTexture, uv).rgb;
|
||||
if (needsPrevious) previous = texture2D(uPreviousTexture, previousUv).rgb;
|
||||
if (needsCurrent) current = texture2D(uTexture, uv).rgb;
|
||||
}
|
||||
|
||||
if (uTextureMix <= 0.001) {
|
||||
@@ -929,6 +936,8 @@ ${GLASS_FLUID_FRAGMENT_SURFACE_REFRACTION}
|
||||
? refracted
|
||||
: sampleChromatic(sourceUv, detailSeparation);
|
||||
refracted = mix(refracted, detailed, mix(0.06, 0.16, uQuality) * (1.0 - frosted));
|
||||
// 非磨砂不使用扩散结果;预滤磨砂已具备低通纹理,两者都无需额外邻域采样。
|
||||
if (frosted > 0.5 && usesPrefilteredFrost <= 0.5) {
|
||||
vec2 diffusionAxis = length(refraction) > 0.00001 ? normalize(refraction) : wakePerpendicular;
|
||||
float diffusionRadius =
|
||||
mix(0.0022, 0.0038, uQuality) *
|
||||
@@ -939,15 +948,11 @@ ${GLASS_FLUID_FRAGMENT_SURFACE_REFRACTION}
|
||||
);
|
||||
float frostedDensity = frosted * (1.0 - uFrostDetailLevel);
|
||||
diffusionRadius *= 1.0 + frostedDensity * mix(1.15, 1.55, uQuality);
|
||||
vec3 diffused;
|
||||
if (usesPrefilteredFrost > 0.5) {
|
||||
diffused = refracted;
|
||||
} else if (uQuality > 0.5) {
|
||||
diffused = sampleHighQualityDiffuse(sourceUv, diffusionAxis, diffusionRadius);
|
||||
} else {
|
||||
diffused = sampleBalancedDiffuse(sourceUv, diffusionAxis, diffusionRadius);
|
||||
}
|
||||
vec3 diffused = uQuality > 0.5
|
||||
? sampleHighQualityDiffuse(sourceUv, diffusionAxis, diffusionRadius)
|
||||
: sampleBalancedDiffuse(sourceUv, diffusionAxis, diffusionRadius);
|
||||
refracted = mix(refracted, diffused, frosted);
|
||||
}
|
||||
float refractedLuminance = dot(refracted, vec3(0.2126, 0.7152, 0.0722));
|
||||
float tinted = step(0.5, uAppearance) * (1.0 - step(1.5, uAppearance));
|
||||
float transmissionOffset = min(uTransmissionStrength - 1.0, 0.0);
|
||||
@@ -1326,12 +1331,19 @@ export function useGlassOpticalRenderer(options: UseGlassOpticalRendererOptions)
|
||||
let contextRecoveryPending = false
|
||||
let resumePromise: Promise<void> | null = null
|
||||
let resumeVersion = 0
|
||||
// 失焦后的暂停状态由活动事件解除,观察器与参数更新不能自行恢复呈现。
|
||||
let presentationPaused = document.visibilityState === 'hidden' || !document.hasFocus()
|
||||
let dynamicsGeneration = 0
|
||||
const presentationSpace = options.surfaceSpace ?? 'fixed'
|
||||
const usesDynamicsOnly = () =>
|
||||
presentationSpace === 'scroll' || (presentationSpace === 'fixed' && toValue(options.appearance) === 'frosted')
|
||||
const wallpaperSourceCache = options.wallpaperSourceCache ?? createGlassWallpaperSourceCache()
|
||||
|
||||
/** 所有持续绘制入口共享活动边界,资源准备和 uniform 同步不依赖呈现帧。 */
|
||||
function canPresentFrame() {
|
||||
return toValue(options.active) && !presentationPaused && document.visibilityState !== 'hidden'
|
||||
}
|
||||
|
||||
/** 滚动期间由原生 backdrop 接管壁纸;稳定态恢复完整纹理折射与流体反馈。 */
|
||||
function syncWallpaperSamplingMode() {
|
||||
if (!resources) return
|
||||
@@ -1347,18 +1359,18 @@ export function useGlassOpticalRenderer(options: UseGlassOpticalRendererOptions)
|
||||
scrollPresentationRestoreTimer = null
|
||||
}
|
||||
|
||||
function finishNativeScrollPresentation(timestamp = performance.now()) {
|
||||
function finishNativeScrollPresentation(timestamp = performance.now(), advanceFlow = false) {
|
||||
clearScrollPresentationRestoreTimer()
|
||||
if (presentationSpace !== 'scroll' || !scrollWallpaperSamplingSuppressed) return
|
||||
if (presentationSpace !== 'scroll' || !scrollWallpaperSamplingSuppressed || !canPresentFrame()) return
|
||||
|
||||
scrollWallpaperSamplingSuppressed = false
|
||||
syncWallpaperSamplingMode()
|
||||
renderFrame(timestamp, false)
|
||||
renderFrame(timestamp, advanceFlow)
|
||||
document.documentElement.removeAttribute('data-glass-scroll-presentation')
|
||||
}
|
||||
|
||||
function beginNativeScrollPresentation() {
|
||||
if (presentationSpace !== 'scroll' || !resources) return
|
||||
if (presentationSpace !== 'scroll' || !resources || !canPresentFrame()) return
|
||||
|
||||
clearScrollPresentationRestoreTimer()
|
||||
scrollPresentationRestoreTimer = window.setTimeout(() => finishNativeScrollPresentation(), 180)
|
||||
@@ -1532,6 +1544,18 @@ export function useGlassOpticalRenderer(options: UseGlassOpticalRendererOptions)
|
||||
transformingSurfaces.clear()
|
||||
}
|
||||
|
||||
/** 暂停和销毁共用几何帧清理,恢复时按最新 DOM 重新测量。 */
|
||||
function cancelSurfaceUpdateFrames() {
|
||||
if (surfaceUpdateFrame !== null) cancelAnimationFrame(surfaceUpdateFrame)
|
||||
surfaceUpdateFrame = null
|
||||
if (surfaceStabilityFrame !== null) cancelAnimationFrame(surfaceStabilityFrame)
|
||||
surfaceStabilityFrame = null
|
||||
if (presentationResizeTimer !== null) window.clearTimeout(presentationResizeTimer)
|
||||
presentationResizeTimer = null
|
||||
presentationResizeCandidate = ''
|
||||
presentationResizeStableSamples = 0
|
||||
}
|
||||
|
||||
function clearBackgroundDisposeTimer() {
|
||||
if (backgroundDisposeTimer === null) return
|
||||
|
||||
@@ -1584,7 +1608,7 @@ export function useGlassOpticalRenderer(options: UseGlassOpticalRendererOptions)
|
||||
|
||||
function renderWallpaperTransitionFrame(timestamp: number) {
|
||||
wallpaperTransitionFrame = null
|
||||
if (document.visibilityState === 'hidden') return
|
||||
if (!canPresentFrame()) return
|
||||
|
||||
renderFrame(timestamp)
|
||||
if (previousTexture && wallpaperTransitionFrame === null) {
|
||||
@@ -1593,7 +1617,7 @@ export function useGlassOpticalRenderer(options: UseGlassOpticalRendererOptions)
|
||||
}
|
||||
|
||||
function scheduleWallpaperTransition() {
|
||||
if (wallpaperTransitionFrame !== null || !previousTexture || document.visibilityState === 'hidden') return
|
||||
if (wallpaperTransitionFrame !== null || !previousTexture || !canPresentFrame()) return
|
||||
|
||||
wallpaperTransitionFrame = requestAnimationFrame(renderWallpaperTransitionFrame)
|
||||
}
|
||||
@@ -1821,12 +1845,23 @@ export function useGlassOpticalRenderer(options: UseGlassOpticalRendererOptions)
|
||||
}
|
||||
|
||||
function renderFrame(timestamp = performance.now(), advanceFlow = true) {
|
||||
if (!resources || !toValue(options.active) || document.visibilityState === 'hidden') return
|
||||
if (!resources || !canPresentFrame()) return
|
||||
|
||||
updateWallpaperTransition(timestamp)
|
||||
if (fluidDynamics && advanceFlow) {
|
||||
resources.uniforms.uFlowTexture.value = fluidDynamics.step()
|
||||
}
|
||||
// 原生 backdrop 接管期间 scroll canvas 已由 CSS 隐藏;保留事务、流场和 uniform 同步,跳过主材质输出。
|
||||
if (
|
||||
presentationSpace === 'scroll' &&
|
||||
scrollWallpaperSamplingSuppressed &&
|
||||
state.value === 'ready' &&
|
||||
document.documentElement.dataset.glassRendererState === 'ready' &&
|
||||
document.documentElement.dataset.glassScrollPresentation === 'native'
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
if (presentationSpace === 'scroll') {
|
||||
const { height: presentationHeight } = getCommittedPresentationSize()
|
||||
const scaleY = presentationBufferHeight / Math.max(presentationHeight, 1)
|
||||
@@ -1848,7 +1883,7 @@ export function useGlassOpticalRenderer(options: UseGlassOpticalRendererOptions)
|
||||
}
|
||||
|
||||
function scheduleFrame() {
|
||||
if (animationFrame !== null || !resources) return
|
||||
if (animationFrame !== null || !resources || !canPresentFrame()) return
|
||||
|
||||
animationFrame = requestAnimationFrame(renderScheduledFrame)
|
||||
}
|
||||
@@ -2130,11 +2165,13 @@ export function useGlassOpticalRenderer(options: UseGlassOpticalRendererOptions)
|
||||
}
|
||||
|
||||
function scheduleSurfaceUpdate() {
|
||||
if (!canPresentFrame()) return
|
||||
if (queueScrollGeometryRefresh(false)) return
|
||||
if (surfaceUpdateFrame !== null || !resources) return
|
||||
|
||||
surfaceUpdateFrame = requestAnimationFrame(timestamp => {
|
||||
surfaceUpdateFrame = null
|
||||
if (!canPresentFrame()) return
|
||||
updateSurfaceUniforms(timestamp, false)
|
||||
// 表面失效必须在同一有界帧内清除旧像素,不能等待下一次指针或壁纸事件。
|
||||
renderFrame(timestamp, false)
|
||||
@@ -2144,6 +2181,7 @@ export function useGlassOpticalRenderer(options: UseGlassOpticalRendererOptions)
|
||||
/** DOM 重排后连续采样少量帧,避免把虚拟列表的中间几何误认为最终表面。 */
|
||||
function scheduleSurfaceStabilityUpdate(motionEpoch?: number) {
|
||||
if (motionEpoch !== undefined) pagePresentationMotionEpoch = motionEpoch
|
||||
if (!canPresentFrame()) return
|
||||
if (queueScrollGeometryRefresh(true)) return
|
||||
surfaceStabilityPass = 0
|
||||
surfaceStableFrameCount = 0
|
||||
@@ -2152,7 +2190,7 @@ export function useGlassOpticalRenderer(options: UseGlassOpticalRendererOptions)
|
||||
|
||||
const sample = (timestamp: number) => {
|
||||
surfaceStabilityFrame = null
|
||||
if (!resources || !toValue(options.active) || document.visibilityState === 'hidden') return
|
||||
if (!resources || !canPresentFrame()) return
|
||||
|
||||
updateSurfaceUniforms(timestamp, false)
|
||||
const signature = surfaceSlots
|
||||
@@ -2192,13 +2230,14 @@ export function useGlassOpticalRenderer(options: UseGlassOpticalRendererOptions)
|
||||
* 连续两个 80ms 样本一致才允许覆盖已提交的 presentation 首帧。
|
||||
*/
|
||||
function schedulePresentationResizeUpdate() {
|
||||
if (!canPresentFrame()) return
|
||||
if (presentationResizeTimer !== null) window.clearTimeout(presentationResizeTimer)
|
||||
presentationResizeCandidate = ''
|
||||
presentationResizeStableSamples = 0
|
||||
|
||||
const sample = () => {
|
||||
presentationResizeTimer = null
|
||||
if (!resources) return
|
||||
if (!resources || !canPresentFrame()) return
|
||||
|
||||
const presentation = measurePresentationSize()
|
||||
const candidate = `${window.innerWidth},${window.innerHeight},${presentation.width},${presentation.height}`
|
||||
@@ -2220,7 +2259,13 @@ export function useGlassOpticalRenderer(options: UseGlassOpticalRendererOptions)
|
||||
|
||||
/** 共享页面 motion 活跃时,页面几何变化必须在浏览器绘制前完成一次完整 presentation 提交。 */
|
||||
function commitActivePagePresentation(timestamp = performance.now()) {
|
||||
if (!resources || presentationSpace !== 'scroll' || !toValue(options.pageMotion?.active ?? false)) return false
|
||||
if (
|
||||
!resources ||
|
||||
!canPresentFrame() ||
|
||||
presentationSpace !== 'scroll' ||
|
||||
!toValue(options.pageMotion?.active ?? false)
|
||||
)
|
||||
return false
|
||||
|
||||
if (presentationResizeTimer !== null) window.clearTimeout(presentationResizeTimer)
|
||||
presentationResizeTimer = null
|
||||
@@ -2235,7 +2280,7 @@ export function useGlassOpticalRenderer(options: UseGlassOpticalRendererOptions)
|
||||
|
||||
/** 普通表面尺寸即时更新;页面根尺寸在稳定后覆盖 presentation。 */
|
||||
function handleSurfaceResize(entries: ResizeObserverEntry[]) {
|
||||
if (!resources) return
|
||||
if (!resources || !canPresentFrame()) return
|
||||
|
||||
const presentationRoot = presentationSpace === 'scroll' ? options.canvas.value?.parentElement : null
|
||||
const presentationChanged = presentationRoot && entries.some(entry => entry.target === presentationRoot)
|
||||
@@ -2251,12 +2296,13 @@ export function useGlassOpticalRenderer(options: UseGlassOpticalRendererOptions)
|
||||
|
||||
/** CSS transform 不改变布局尺寸,过渡期间用有界帧同步真实几何并清除旧蒙版。 */
|
||||
function scheduleSurfaceTransformFrame() {
|
||||
if (!canPresentFrame()) return
|
||||
if (queueScrollGeometryRefresh(false)) return
|
||||
if (surfaceTransformFrame !== null || !resources) return
|
||||
|
||||
surfaceTransformFrame = requestAnimationFrame(timestamp => {
|
||||
surfaceTransformFrame = null
|
||||
if (!resources || !toValue(options.active) || document.visibilityState === 'hidden') {
|
||||
if (!resources || !canPresentFrame()) {
|
||||
cancelSurfaceTransformFrame()
|
||||
return
|
||||
}
|
||||
@@ -2444,7 +2490,7 @@ export function useGlassOpticalRenderer(options: UseGlassOpticalRendererOptions)
|
||||
}
|
||||
|
||||
function resizeRenderer() {
|
||||
if (!resources) return
|
||||
if (!resources || !canPresentFrame()) return
|
||||
|
||||
const viewportWidth = window.innerWidth
|
||||
const viewportHeight = window.innerHeight
|
||||
@@ -2649,7 +2695,7 @@ export function useGlassOpticalRenderer(options: UseGlassOpticalRendererOptions)
|
||||
}
|
||||
|
||||
function renderInteractionFrame(timestamp: number) {
|
||||
if (!resources || !toValue(options.active) || document.visibilityState === 'hidden') {
|
||||
if (!resources || !canPresentFrame()) {
|
||||
animationFrame = null
|
||||
interactionAnimating = false
|
||||
return
|
||||
@@ -2714,7 +2760,7 @@ export function useGlassOpticalRenderer(options: UseGlassOpticalRendererOptions)
|
||||
}
|
||||
|
||||
function startInteractionAnimation() {
|
||||
if (interactionAnimating) return
|
||||
if (interactionAnimating || !canPresentFrame()) return
|
||||
|
||||
cancelScheduledFrame()
|
||||
interactionAnimating = true
|
||||
@@ -2731,6 +2777,7 @@ export function useGlassOpticalRenderer(options: UseGlassOpticalRendererOptions)
|
||||
target?: EventTarget | null,
|
||||
) {
|
||||
if (
|
||||
!canPresentFrame() ||
|
||||
!hasDynamicCapability() ||
|
||||
(hasRippleCapability() && presentationSpace === 'scroll' && scrollWallpaperSamplingSuppressed)
|
||||
) {
|
||||
@@ -2929,12 +2976,7 @@ export function useGlassOpticalRenderer(options: UseGlassOpticalRendererOptions)
|
||||
scrollAnimationFrame = null
|
||||
scrollFrameCommitted = false
|
||||
scrollLateGeometryCommitted = false
|
||||
if (
|
||||
presentationSpace !== 'scroll' ||
|
||||
!resources ||
|
||||
!toValue(options.active) ||
|
||||
document.visibilityState === 'hidden'
|
||||
) {
|
||||
if (presentationSpace !== 'scroll' || !resources || !canPresentFrame()) {
|
||||
scrollDirty = false
|
||||
scrollStableFrameCount = 0
|
||||
return
|
||||
@@ -2980,13 +3022,13 @@ export function useGlassOpticalRenderer(options: UseGlassOpticalRendererOptions)
|
||||
}
|
||||
|
||||
function scheduleScrollFrame() {
|
||||
if (scrollAnimationFrame !== null || presentationSpace !== 'scroll' || !resources) return
|
||||
if (scrollAnimationFrame !== null || presentationSpace !== 'scroll' || !resources || !canPresentFrame()) return
|
||||
|
||||
scrollAnimationFrame = requestAnimationFrame(renderScrollFrame)
|
||||
}
|
||||
|
||||
function handleScroll(event: Event) {
|
||||
if (presentationSpace !== 'scroll' || !resources) return
|
||||
if (presentationSpace !== 'scroll' || !resources || !canPresentFrame()) return
|
||||
|
||||
const target = event.target
|
||||
if (!isRelevantScrollTarget(target)) return
|
||||
@@ -3023,7 +3065,7 @@ export function useGlassOpticalRenderer(options: UseGlassOpticalRendererOptions)
|
||||
}
|
||||
|
||||
function handleScrollEnd(event: Event) {
|
||||
if (presentationSpace !== 'scroll' || !resources) return
|
||||
if (presentationSpace !== 'scroll' || !resources || !canPresentFrame()) return
|
||||
|
||||
const target = event.target
|
||||
if (
|
||||
@@ -3041,20 +3083,25 @@ export function useGlassOpticalRenderer(options: UseGlassOpticalRendererOptions)
|
||||
|
||||
/** 暂停事件驱动帧但保留 WebGL context、纹理、流场和最后一张稳定画面。 */
|
||||
function pauseRenderer() {
|
||||
presentationPaused = true
|
||||
resumeVersion += 1
|
||||
resumePromise = null
|
||||
cancelScheduledFrame()
|
||||
cancelScrollFrame()
|
||||
finishNativeScrollPresentation()
|
||||
// 原生滚动背板保持接管,恢复时先提交正确像素再揭示 canvas。
|
||||
cancelWallpaperTransitionFrame()
|
||||
cancelSurfaceTransformFrame()
|
||||
cancelSurfaceUpdateFrames()
|
||||
interactionAnimating = false
|
||||
}
|
||||
|
||||
/** 合并同一可见性事务的多个浏览器事件,只恢复一次稳定帧。 */
|
||||
function resumeRenderer() {
|
||||
if (!canPresentFrame()) return Promise.resolve()
|
||||
if (resumePromise) return resumePromise
|
||||
|
||||
const version = resumeVersion
|
||||
const canResume = () => toValue(options.active) && document.visibilityState !== 'hidden'
|
||||
const canResume = () => canPresentFrame()
|
||||
const task = (async () => {
|
||||
clearBackgroundDisposeTimer()
|
||||
if (!canResume()) return
|
||||
@@ -3063,6 +3110,7 @@ export function useGlassOpticalRenderer(options: UseGlassOpticalRendererOptions)
|
||||
if (version !== resumeVersion || !canResume()) return
|
||||
if (!resources) {
|
||||
await initializeRenderer()
|
||||
if (canPresentFrame() && !pagePresentationGeometryReady) scheduleSurfaceStabilityUpdate()
|
||||
return
|
||||
}
|
||||
|
||||
@@ -3071,12 +3119,14 @@ export function useGlassOpticalRenderer(options: UseGlassOpticalRendererOptions)
|
||||
const timestamp = performance.now()
|
||||
const keepRippleAnimating = hasRippleCapability() ? advanceRipple(timestamp) : false
|
||||
if (!keepRippleAnimating) resetInteractionState()
|
||||
renderFrame(timestamp, !hasRippleCapability())
|
||||
if (scrollWallpaperSamplingSuppressed) finishNativeScrollPresentation(timestamp, !hasRippleCapability())
|
||||
else renderFrame(timestamp, !hasRippleCapability())
|
||||
if (keepRippleAnimating) {
|
||||
interactionAnimating = true
|
||||
animationFrame = requestAnimationFrame(renderInteractionFrame)
|
||||
}
|
||||
scheduleWallpaperTransition()
|
||||
if (!pagePresentationGeometryReady) scheduleSurfaceStabilityUpdate()
|
||||
})()
|
||||
|
||||
resumePromise = task
|
||||
@@ -3100,12 +3150,13 @@ export function useGlassOpticalRenderer(options: UseGlassOpticalRendererOptions)
|
||||
}
|
||||
|
||||
function handleVisibilityChange() {
|
||||
if (document.visibilityState === 'hidden') {
|
||||
if (document.visibilityState === 'hidden' || !document.hasFocus()) {
|
||||
pauseRenderer()
|
||||
scheduleInactiveRendererDisposal(() => document.visibilityState === 'hidden')
|
||||
scheduleInactiveRendererDisposal(() => document.visibilityState === 'hidden' || !document.hasFocus())
|
||||
return
|
||||
}
|
||||
|
||||
presentationPaused = false
|
||||
void resumeRenderer()
|
||||
}
|
||||
|
||||
@@ -3116,8 +3167,15 @@ export function useGlassOpticalRenderer(options: UseGlassOpticalRendererOptions)
|
||||
scheduleInactiveRendererDisposal(() => document.visibilityState === 'visible' && !document.hasFocus())
|
||||
}
|
||||
|
||||
function handleWindowResume() {
|
||||
if (document.visibilityState === 'visible') void resumeRenderer()
|
||||
function handleWindowResume(event: Event) {
|
||||
if (document.visibilityState !== 'visible') return
|
||||
if (event.type === 'pageshow' && !document.hasFocus()) {
|
||||
handleWindowBlur()
|
||||
return
|
||||
}
|
||||
|
||||
presentationPaused = false
|
||||
void resumeRenderer()
|
||||
}
|
||||
|
||||
function handleContextLost(event: Event) {
|
||||
@@ -3339,21 +3397,8 @@ export function useGlassOpticalRenderer(options: UseGlassOpticalRendererOptions)
|
||||
cancelScrollFrame()
|
||||
cancelWallpaperTransitionFrame()
|
||||
cancelSurfaceTransformFrame()
|
||||
cancelSurfaceUpdateFrames()
|
||||
clearBackgroundDisposeTimer()
|
||||
if (surfaceUpdateFrame !== null) {
|
||||
cancelAnimationFrame(surfaceUpdateFrame)
|
||||
surfaceUpdateFrame = null
|
||||
}
|
||||
if (surfaceStabilityFrame !== null) {
|
||||
cancelAnimationFrame(surfaceStabilityFrame)
|
||||
surfaceStabilityFrame = null
|
||||
}
|
||||
if (presentationResizeTimer !== null) {
|
||||
window.clearTimeout(presentationResizeTimer)
|
||||
presentationResizeTimer = null
|
||||
}
|
||||
presentationResizeCandidate = ''
|
||||
presentationResizeStableSamples = 0
|
||||
removeEvents()
|
||||
resizeObserver?.disconnect()
|
||||
resizeObserver = null
|
||||
@@ -3983,6 +4028,9 @@ export function useGlassOpticalRenderer(options: UseGlassOpticalRendererOptions)
|
||||
resizeRenderer()
|
||||
await loadWallpaper(toValue(options.wallpaperUrl), version)
|
||||
preparePendingWallpaper()
|
||||
if (version === loadVersion && presentationPaused) {
|
||||
scheduleInactiveRendererDisposal(() => document.visibilityState === 'hidden' || !document.hasFocus())
|
||||
}
|
||||
} catch (error) {
|
||||
fallbackFromCurrentLoad(version, '玻璃光学渲染器初始化失败,已回退标准材质:', error)
|
||||
}
|
||||
|
||||
@@ -189,7 +189,7 @@ describe('glass overlay material styles', () => {
|
||||
|
||||
expect(styles).toContain('--glass-fixed-shell-backplate-filter: blur(min(var(--glass-blur-raised), 60px))')
|
||||
expect(styles).toMatch(
|
||||
/&\[data-glass-appearance='frosted'\]\[data-glass-quality='css'\][\s\S]*?\.layout-wrapper\.layout-fixed-shell-backplate-active \.layout-vertical-nav::before,[\s\S]*?\.layout-wrapper\.layout-fixed-shell-backplate-active \.layout-navbar,[\s\S]*?backdrop-filter:\s*none\s*!important;/,
|
||||
/&\[data-glass-appearance='frosted'\]\[data-glass-quality='css'\]\s+body\[data-theme='glass'\][\s\S]*?\.layout-wrapper\.layout-fixed-shell-backplate-active \.layout-vertical-nav::before,[\s\S]*?\.layout-wrapper\.layout-fixed-shell-backplate-active \.layout-navbar,[\s\S]*?backdrop-filter:\s*none\s*!important;/,
|
||||
)
|
||||
expect(styles).toMatch(
|
||||
/\[data-glass-appearance='frosted'\]\[data-glass-quality='balanced'\]\s*\{[\s\S]*?--glass-fixed-shell-backplate-filter:\s*var\(--glass-native-surface-backdrop-filter\);/,
|
||||
@@ -226,6 +226,42 @@ describe('glass overlay material styles', () => {
|
||||
expect(overlayBackplateRule).toMatch(/transition:\s*clip-path 0\.25s ease-in-out/u)
|
||||
})
|
||||
|
||||
it('keeps desktop sidebar refraction isolated from the attached navbar and mobile Drawer', () => {
|
||||
const styles = readFileSync(resolve(cwd(), 'src/styles/themes/glass.scss'), 'utf8')
|
||||
const defs = readFileSync(resolve(cwd(), 'src/components/theme/GlassNavbarRefractionDefs.vue'), 'utf8')
|
||||
const layout = readFileSync(resolve(cwd(), 'src/@layouts/components/VerticalNavLayout.vue'), 'utf8')
|
||||
|
||||
expect(styles).toContain('--glass-sidebar-live-filter: var(--glass-fixed-shell-backdrop-filter)')
|
||||
expect(styles).toContain('--glass-sidebar-diffusion-blur: clamp(')
|
||||
expect(styles).toContain('--glass-sidebar-absorption-start: clamp(')
|
||||
expect(styles).toContain('--glass-sidebar-absorption-end: clamp(')
|
||||
expect(styles).toContain('--glass-sidebar-edge-opacity: clamp(')
|
||||
expect(styles).toMatch(
|
||||
/\.layout-vertical-nav\s*\{[\s\S]*?&::before\s*\{[\s\S]*?backdrop-filter:\s*var\(--glass-sidebar-live-filter\);[\s\S]*?background-image:\s*var\(--glass-sheen\)/,
|
||||
)
|
||||
expect(styles).toMatch(
|
||||
/\.layout-wrapper\[data-glass-navigation-refraction='chromium'\]\[data-glass-sidebar-refraction-ready='true'\][\s\S]*?\.layout-vertical-nav:not\(\.overlay-nav\)\s*\{[\s\S]*?url\('#glass-sidebar-live-refraction-high'\)/,
|
||||
)
|
||||
expect(styles).toMatch(
|
||||
/\.layout-wrapper\[data-glass-navigation-refraction='chromium'\]\[data-glass-sidebar-refraction-ready='true'\][\s\S]*?\.layout-vertical-nav:not\(\.overlay-nav\)\s*\{[\s\S]*?url\('#glass-sidebar-live-refraction-balanced'\)/,
|
||||
)
|
||||
expect(styles).toMatch(
|
||||
/&\[data-glass-appearance='frosted'\]\s*\{[\s\S]*?\.layout-vertical-nav::before\s*\{[\s\S]*?var\(--glass-sidebar-absorption-start\)[\s\S]*?var\(--glass-sidebar-absorption-end\)[\s\S]*?var\(--glass-sidebar-edge-opacity\)/,
|
||||
)
|
||||
expect(defs).toContain('id="glass-sidebar-live-refraction-balanced"')
|
||||
expect(defs).toContain('id="glass-sidebar-live-refraction-high"')
|
||||
expect(styles).not.toContain("url('#glass-sidebar-live-refraction-high') var(--glass-fixed-shell-backdrop-filter)")
|
||||
expect(styles).toContain('--glass-sidebar-live-filter: none !important')
|
||||
expect(styles).toContain('--glass-fixed-shell-backplate-filter: var(--glass-sidebar-backdrop-filter)')
|
||||
expect(styles).toContain('--glass-navbar-scrolled-backdrop-filter: none')
|
||||
expect(defs).toContain("readyAttribute: 'data-glass-sidebar-refraction-ready'")
|
||||
expect(layout).toContain("'data-glass-navigation-refraction': navbarRefractionMode")
|
||||
expect(layout).toContain("'data-glass-navbar-refraction': navbarRefractionMode")
|
||||
expect(styles).not.toContain(
|
||||
".layout-wrapper[data-glass-navigation-refraction='chromium'][data-glass-sidebar-refraction-ready='true']\n .layout-navbar",
|
||||
)
|
||||
})
|
||||
|
||||
it('limits detached navbar geometry to eligible Transparent and Glass horizontal shells', () => {
|
||||
const layout = readFileSync(resolve(cwd(), 'src/@layouts/components/VerticalNavLayout.vue'), 'utf8')
|
||||
|
||||
@@ -392,4 +428,83 @@ 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\);/,
|
||||
)
|
||||
})
|
||||
|
||||
it('keeps floating clear and tinted navbars on CSS material until Chromium SVG is ready', () => {
|
||||
const styles = readFileSync(resolve(cwd(), 'src/styles/themes/glass.scss'), 'utf8')
|
||||
const baseMaterialStart = styles.lastIndexOf('// 基础材质由单一真实表面承载')
|
||||
const svgEnhancementStart = styles.indexOf('// 只有已确认的 Chromium SVG 能力')
|
||||
const reducedTransparencyStart = styles.lastIndexOf('@media (prefers-reduced-transparency: reduce)')
|
||||
const reducedMotionStart = styles.lastIndexOf('@media (prefers-reduced-motion: reduce)')
|
||||
const tintedNavbarStart = styles.indexOf(
|
||||
"html[data-theme='glass'][data-glass-appearance='tinted']",
|
||||
svgEnhancementStart,
|
||||
)
|
||||
const baseMaterialRule = styles.slice(baseMaterialStart, svgEnhancementStart)
|
||||
const svgFilterRule = styles.slice(svgEnhancementStart, tintedNavbarStart)
|
||||
const reducedTransparencyRule = styles.slice(reducedTransparencyStart, reducedMotionStart)
|
||||
const reducedMotionRule = styles.slice(reducedMotionStart)
|
||||
|
||||
expect(baseMaterialStart).toBeGreaterThanOrEqual(0)
|
||||
expect(svgEnhancementStart).toBeGreaterThan(baseMaterialStart)
|
||||
expect(baseMaterialRule).toContain("[data-glass-appearance='clear'], [data-glass-appearance='tinted']")
|
||||
expect(baseMaterialRule).toContain('--glass-navbar-opacity: clamp(')
|
||||
expect(baseMaterialRule).toContain(
|
||||
'--glass-navbar-reflection-ratio: clamp(0, calc(var(--glass-reflection, 0.38) * 2.6316), 1.7)',
|
||||
)
|
||||
expect(baseMaterialRule).toContain(
|
||||
'--glass-navbar-sheen-opacity: clamp(0, calc(0.18 * var(--glass-navbar-reflection-ratio)), 0.3)',
|
||||
)
|
||||
expect(baseMaterialRule).toContain(
|
||||
'--glass-navbar-rim-opacity: clamp(0, calc(0.24 * var(--glass-navbar-reflection-ratio)), 0.4)',
|
||||
)
|
||||
expect(baseMaterialRule).toContain(
|
||||
'--glass-navbar-top-opacity: clamp(0, calc(0.4 * var(--glass-navbar-reflection-ratio)), 0.65)',
|
||||
)
|
||||
expect(baseMaterialRule).toContain('var(--glass-background-visibility, 0.58)')
|
||||
expect(baseMaterialRule).toContain('var(--glass-surface-density, 0.62)')
|
||||
expect(baseMaterialRule).toContain('--glass-navbar-blur: clamp(')
|
||||
expect(baseMaterialRule).toContain('0.62px + var(--glass-surface-density, 0.62) * 0.8px')
|
||||
expect(baseMaterialRule).toContain('- var(--glass-background-visibility, 0.58) * 0.25px')
|
||||
expect(baseMaterialRule).toContain('--glass-navbar-brightness: var(--glass-transmission-brightness, 1)')
|
||||
expect(baseMaterialRule).toContain('--glass-navbar-saturation: clamp(')
|
||||
expect(baseMaterialRule).toContain('--glass-navbar-sheen: linear-gradient(')
|
||||
expect(baseMaterialRule).toContain('var(--glass-navbar-reflection-ratio)')
|
||||
expect(baseMaterialRule).toContain('--glass-navbar-scrim: linear-gradient(')
|
||||
expect(baseMaterialRule).toContain(
|
||||
'--glass-navbar-tint: clamp(0, calc(var(--glass-tint-density, 0.65) * 0.12), 0.18)',
|
||||
)
|
||||
expect(baseMaterialRule).toContain('--glass-navbar-live-filter: blur(var(--glass-navbar-blur))')
|
||||
expect(baseMaterialRule).toContain('brightness(var(--glass-navbar-brightness))')
|
||||
expect(baseMaterialRule).toContain('background: var(--glass-navbar-sheen), var(--glass-navbar-scrim) !important')
|
||||
expect(baseMaterialRule).toContain('box-shadow: var(--glass-navbar-shadow) !important')
|
||||
expect(baseMaterialRule).toContain('border: 0 !important')
|
||||
expect(baseMaterialRule).toContain('inset-block-start: var(--shell-floating-navbar-inset) !important')
|
||||
expect(baseMaterialRule).toContain('inset-inline: var(--shell-floating-navbar-inset) !important')
|
||||
expect(baseMaterialRule).not.toContain('data-glass-navbar-refraction-ready')
|
||||
expect(baseMaterialRule).not.toContain('&::before')
|
||||
expect(baseMaterialRule).not.toContain('&::after')
|
||||
expect(baseMaterialRule).not.toContain("url('#glass-navbar-live-refraction-")
|
||||
expect(svgFilterRule).toContain("data-glass-navbar-refraction-ready='true'")
|
||||
expect(svgFilterRule).toContain("url('#glass-navbar-live-refraction-balanced')")
|
||||
expect(svgFilterRule).toContain("url('#glass-navbar-live-refraction-high')")
|
||||
expect(svgFilterRule).toContain('blur(var(--glass-navbar-blur))')
|
||||
expect(svgFilterRule).toContain('saturate(var(--glass-navbar-saturation))')
|
||||
expect(svgFilterRule).toContain('brightness(var(--glass-navbar-brightness))')
|
||||
expect(svgFilterRule).not.toContain('background:')
|
||||
expect(svgFilterRule).not.toContain('box-shadow:')
|
||||
expect(svgFilterRule).not.toContain('border:')
|
||||
expect(tintedNavbarStart).toBeGreaterThan(svgEnhancementStart)
|
||||
expect(styles.slice(tintedNavbarStart, reducedTransparencyStart)).toContain('var(--glass-material-accent-rgb)')
|
||||
expect(styles.slice(tintedNavbarStart, reducedTransparencyStart)).toContain('var(--glass-navbar-tint)')
|
||||
expect(reducedTransparencyRule).toContain('--glass-navbar-live-filter: none !important')
|
||||
expect(reducedTransparencyRule).toContain('-webkit-backdrop-filter: none !important')
|
||||
expect(reducedTransparencyRule).toContain('backdrop-filter: none !important')
|
||||
expect(reducedTransparencyRule).toContain('background: rgb(11, 19, 34) !important')
|
||||
expect(reducedTransparencyRule).toContain('background-image: none !important')
|
||||
expect(reducedMotionRule).toContain('.layout-navbar')
|
||||
expect(reducedMotionRule).toContain('.navbar-content-container')
|
||||
expect(reducedMotionRule).toContain('transition: none !important')
|
||||
expect(reducedMotionRule).not.toContain('inset-block-start: 0 !important')
|
||||
expect(reducedMotionRule).not.toContain('inset-inline: 0 !important')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -0,0 +1,309 @@
|
||||
@use '@configured-variables' as variables;
|
||||
|
||||
// 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 .site-card .border-t .text-medium-emphasis {
|
||||
// 上传/下载是核心读数,信息权重高于网址和能力标识。
|
||||
color: rgb(var(--v-theme-on-surface)) !important;
|
||||
font-weight: 600;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.layout-page-content .site-card h3,
|
||||
.layout-page-content .plugin-card__banner .v-card-title {
|
||||
font-weight: 650;
|
||||
letter-spacing: 0;
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 固定导航保留布局占位,玻璃实体在占位内留出空气,采样几何仍由真实元素提供。
|
||||
html[data-theme='glass']
|
||||
body[data-theme='glass']
|
||||
.layout-wrapper[data-shell-mode='desktop']:not(
|
||||
.layout-horizontal-nav-active,
|
||||
.layout-window-controls-overlay-shell
|
||||
) {
|
||||
--glass-v3-nav-reserved-width: #{variables.$layout-vertical-nav-width};
|
||||
|
||||
&.layout-vertical-nav-collapsed {
|
||||
--glass-v3-nav-reserved-width: #{variables.$layout-vertical-nav-collapsed-width};
|
||||
}
|
||||
|
||||
.layout-vertical-nav {
|
||||
border-radius: 16px;
|
||||
box-shadow: var(--glass-v3-shadow) !important;
|
||||
block-size: calc(100% - 16px);
|
||||
inline-size: calc(var(--glass-v3-nav-reserved-width) - 8px) !important;
|
||||
inset-block-start: 8px;
|
||||
inset-inline-start: 8px;
|
||||
overflow: clip;
|
||||
}
|
||||
|
||||
&.layout-vertical-nav-collapsed .layout-vertical-nav.hovered {
|
||||
inline-size: calc(#{variables.$layout-vertical-nav-width} - 8px) !important;
|
||||
}
|
||||
|
||||
.layout-navbar {
|
||||
border-radius: 16px !important;
|
||||
inline-size: calc(100% - var(--glass-v3-nav-reserved-width) - 16px) !important;
|
||||
inset-block-start: 8px !important;
|
||||
inset-inline-start: calc(var(--glass-v3-nav-reserved-width) + 8px) !important;
|
||||
overflow: clip;
|
||||
}
|
||||
}
|
||||
|
||||
@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'] {
|
||||
@@ -53,6 +54,8 @@ html[data-theme='glass'] {
|
||||
--glass-control-prominent-backdrop-filter: none;
|
||||
--glass-navbar-backdrop-filter: var(--glass-raised-backdrop-filter);
|
||||
--glass-navbar-scrolled-backdrop-filter: blur(3px) saturate(115%);
|
||||
--glass-navbar-live-filter: none;
|
||||
--glass-sidebar-live-filter: var(--glass-fixed-shell-backdrop-filter);
|
||||
--glass-overlay-surface: rgba(11, 19, 34, calc(0.1 + var(--glass-surface-density, 0.62) * 0.22));
|
||||
--glass-overlay-blur: var(--glass-overlay-clarity-blur, 6px);
|
||||
--glass-overlay-saturate: 115%;
|
||||
@@ -227,6 +230,35 @@ html[data-theme='glass'] {
|
||||
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-sidebar-diffusion-blur: clamp(
|
||||
14px,
|
||||
calc(6px + var(--glass-surface-density, 0.86) * 16px + (1 - var(--glass-background-visibility, 0.58)) * 6px),
|
||||
28px
|
||||
);
|
||||
--glass-sidebar-diffusion-saturation: clamp(
|
||||
126%,
|
||||
calc(112% + var(--glass-background-visibility, 0.58) * 30% + var(--glass-surface-density, 0.86) * 8%),
|
||||
154%
|
||||
);
|
||||
--glass-sidebar-absorption-start: clamp(
|
||||
0.18,
|
||||
calc(0.08 + var(--glass-surface-density, 0.86) * 0.2 + (1 - var(--glass-background-visibility, 0.58)) * 0.12),
|
||||
0.42
|
||||
);
|
||||
--glass-sidebar-absorption-end: clamp(
|
||||
0.28,
|
||||
calc(var(--glass-sidebar-absorption-start) + 0.12 + (1 - var(--glass-background-visibility, 0.58)) * 0.08),
|
||||
0.54
|
||||
);
|
||||
--glass-sidebar-edge-opacity: clamp(
|
||||
0.12,
|
||||
calc(0.08 + var(--glass-reflection, 0.5) * 0.22 + (1 - var(--glass-background-visibility, 0.58)) * 0.06),
|
||||
0.4
|
||||
);
|
||||
--glass-sidebar-backdrop-filter: blur(var(--glass-sidebar-diffusion-blur))
|
||||
saturate(var(--glass-sidebar-diffusion-saturation)) brightness(var(--glass-transmission-brightness));
|
||||
--glass-sidebar-live-filter: var(--glass-sidebar-backdrop-filter);
|
||||
--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-blur: min(var(--glass-blur-raised), 36px);
|
||||
@@ -489,8 +521,8 @@ html[data-theme='glass'] {
|
||||
position: absolute;
|
||||
z-index: -1;
|
||||
border-inline-end: 1px solid var(--glass-border-raised);
|
||||
-webkit-backdrop-filter: var(--glass-fixed-shell-backdrop-filter);
|
||||
backdrop-filter: var(--glass-fixed-shell-backdrop-filter);
|
||||
-webkit-backdrop-filter: var(--glass-sidebar-live-filter);
|
||||
backdrop-filter: var(--glass-sidebar-live-filter);
|
||||
background-color: var(--glass-surface-raised);
|
||||
background-image: var(--glass-sheen);
|
||||
box-shadow: var(--glass-shadow-raised);
|
||||
@@ -500,6 +532,59 @@ html[data-theme='glass'] {
|
||||
}
|
||||
}
|
||||
|
||||
&[data-glass-appearance='frosted'] {
|
||||
.layout-wrapper[data-shell-mode='desktop'] {
|
||||
--glass-navbar-backdrop-filter: var(--glass-sidebar-backdrop-filter);
|
||||
}
|
||||
|
||||
// Frosted 的大表面先扩散再吸收背景,边缘高光只保留在真实 DOM 背板的外轮廓。
|
||||
.layout-wrapper[data-shell-mode='desktop'] .layout-vertical-nav::before {
|
||||
border-inline-end: 0;
|
||||
background-color: transparent;
|
||||
background-image:
|
||||
var(--glass-sheen),
|
||||
linear-gradient(
|
||||
90deg,
|
||||
rgba(7, 14, 25, var(--glass-sidebar-absorption-start)),
|
||||
rgba(7, 14, 25, var(--glass-sidebar-absorption-end))
|
||||
);
|
||||
box-shadow:
|
||||
inset -1px 0 0 rgba(255, 255, 255, var(--glass-sidebar-edge-opacity)),
|
||||
0 8px 24px rgba(3, 7, 18, 0.12);
|
||||
}
|
||||
|
||||
.layout-wrapper[data-shell-mode='desktop'] > .glass-fixed-shell-backplate--main {
|
||||
// 固定顶栏与侧栏共用同一 L 形稳定背板,扩散参数必须作用于真实承载层。
|
||||
--glass-fixed-shell-backplate-filter: var(--glass-sidebar-backdrop-filter);
|
||||
}
|
||||
|
||||
.layout-wrapper[data-shell-mode='desktop'].layout-fixed-shell-backplate-active {
|
||||
--glass-navbar-scrolled-backdrop-filter: none;
|
||||
|
||||
.layout-navbar,
|
||||
.layout-vertical-nav::before {
|
||||
-webkit-backdrop-filter: none !important;
|
||||
backdrop-filter: none !important;
|
||||
}
|
||||
}
|
||||
|
||||
.layout-wrapper[data-shell-mode='desktop']:not(.layout-fixed-shell-backplate-active) .layout-vertical-nav::before,
|
||||
.layout-wrapper[data-shell-mode='desktop']:not(.layout-fixed-shell-backplate-active) .layout-navbar {
|
||||
-webkit-backdrop-filter: var(--glass-sidebar-backdrop-filter) !important;
|
||||
backdrop-filter: var(--glass-sidebar-backdrop-filter) !important;
|
||||
}
|
||||
|
||||
.layout-wrapper[data-shell-mode='desktop']:not(.layout-fixed-shell-backplate-active) .layout-navbar {
|
||||
// 原生回退没有壁纸背板的吸收层,独立保证繁忙页面下的文字对比。
|
||||
background:
|
||||
var(--glass-sheen),
|
||||
linear-gradient(
|
||||
rgba(7, 14, 25, var(--glass-sidebar-absorption-start)),
|
||||
rgba(7, 14, 25, var(--glass-sidebar-absorption-end))
|
||||
) !important;
|
||||
}
|
||||
}
|
||||
|
||||
// Chromium 磨砂 fixed 层直接渲染稳定壁纸背板,固定功能层不得读取随页面重绘的 document backdrop。
|
||||
&[data-glass-appearance='frosted'][data-glass-quality='css'] body[data-theme='glass'] {
|
||||
.layout-wrapper.layout-fixed-shell-backplate-active .layout-vertical-nav::before,
|
||||
@@ -1651,3 +1736,199 @@ html[data-theme='glass'] body[data-theme='glass'] .native-login-field:focus-with
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
|
||||
// 浮动顶栏使用同一布局坐标系,只插值真实 inset,避免切换 transform 采样空间。
|
||||
html[data-theme='glass']:is([data-glass-appearance='clear'], [data-glass-appearance='tinted'])
|
||||
body[data-theme='glass']
|
||||
.layout-wrapper.layout-navbar-floating-eligible
|
||||
.layout-navbar {
|
||||
inline-size: auto !important;
|
||||
inset-block-start: 0 !important;
|
||||
inset-inline: 0 !important;
|
||||
transform: none !important;
|
||||
transition:
|
||||
background-color var(--shell-floating-navbar-motion-duration) var(--shell-floating-navbar-motion-easing),
|
||||
border-color var(--shell-floating-navbar-motion-duration) var(--shell-floating-navbar-motion-easing),
|
||||
border-radius var(--shell-floating-navbar-motion-duration) var(--shell-floating-navbar-motion-easing),
|
||||
box-shadow var(--shell-floating-navbar-motion-duration) var(--shell-floating-navbar-motion-easing),
|
||||
inset-block-start var(--shell-floating-navbar-motion-duration) var(--shell-floating-navbar-motion-easing),
|
||||
inset-inline-end var(--shell-floating-navbar-motion-duration) var(--shell-floating-navbar-motion-easing),
|
||||
inset-inline-start var(--shell-floating-navbar-motion-duration) var(--shell-floating-navbar-motion-easing) !important;
|
||||
|
||||
.navbar-content-container {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
// 外壳内收时,导航内容仍按视口中心对齐,避免控件位置跟随采样面的宽度变化。
|
||||
inline-size: min(100vw, variables.$layout-boxed-content-width);
|
||||
inset-inline-start: 50%;
|
||||
transform: translateX(-50%) !important;
|
||||
}
|
||||
}
|
||||
|
||||
// 基础材质由单一真实表面承载;柔和迎光表达厚度,不叠加父子两层硬高光。
|
||||
html[data-theme='glass']:is([data-glass-appearance='clear'], [data-glass-appearance='tinted'])
|
||||
body[data-theme='glass']
|
||||
.layout-wrapper.layout-navbar-floating-eligible.layout-navbar-away-from-top
|
||||
.layout-navbar {
|
||||
// 共享材质响应只改变浮动表面的光学参数,不把内容层一起变暗或变透明。
|
||||
// 0.38 是 Natural clear/high 的反射参考值,三个亮边在该点保持默认材质能量。
|
||||
--glass-navbar-reflection-ratio: clamp(0, calc(var(--glass-reflection, 0.38) * 2.6316), 1.7);
|
||||
--glass-navbar-sheen-opacity: clamp(0, calc(0.18 * var(--glass-navbar-reflection-ratio)), 0.3);
|
||||
--glass-navbar-rim-opacity: clamp(0, calc(0.24 * var(--glass-navbar-reflection-ratio)), 0.4);
|
||||
--glass-navbar-top-opacity: clamp(0, calc(0.4 * var(--glass-navbar-reflection-ratio)), 0.65);
|
||||
--glass-navbar-opacity: clamp(
|
||||
0,
|
||||
calc(0.02 + var(--glass-surface-density, 0.62) * 0.05 + (1 - var(--glass-background-visibility, 0.58)) * 0.02),
|
||||
0.2
|
||||
);
|
||||
--glass-navbar-scrim-end: clamp(
|
||||
0,
|
||||
calc(0.045 + var(--glass-surface-density, 0.62) * 0.1 + (1 - var(--glass-background-visibility, 0.58)) * 0.05),
|
||||
0.28
|
||||
);
|
||||
--glass-navbar-blur: clamp(
|
||||
0.35px,
|
||||
calc(0.62px + var(--glass-surface-density, 0.62) * 0.8px - var(--glass-background-visibility, 0.58) * 0.25px),
|
||||
1.4px
|
||||
);
|
||||
--glass-navbar-brightness: var(--glass-transmission-brightness, 1);
|
||||
--glass-navbar-saturation: clamp(
|
||||
110%,
|
||||
calc(110% + var(--glass-background-visibility, 0.58) * 12% + var(--glass-tint-density, 0.65) * 4%),
|
||||
124%
|
||||
);
|
||||
--glass-navbar-sheen: linear-gradient(
|
||||
145deg,
|
||||
rgba(255, 255, 255, var(--glass-navbar-sheen-opacity)),
|
||||
transparent 38%
|
||||
);
|
||||
--glass-navbar-rim: rgba(255, 255, 255, var(--glass-navbar-rim-opacity));
|
||||
--glass-navbar-scrim: linear-gradient(
|
||||
rgba(7, 14, 25, var(--glass-navbar-opacity)),
|
||||
rgba(7, 14, 25, var(--glass-navbar-scrim-end))
|
||||
);
|
||||
--glass-navbar-tint: clamp(0, calc(var(--glass-tint-density, 0.65) * 0.12), 0.18);
|
||||
--glass-navbar-shadow:
|
||||
inset 0 0 0 1px var(--glass-navbar-rim), inset 0 1px 2px rgba(255, 255, 255, var(--glass-navbar-top-opacity)),
|
||||
inset 0 -1px 2px rgba(4, 10, 20, calc(0.1 + var(--glass-surface-density, 0.62) * 0.12)),
|
||||
0 12px 32px
|
||||
rgba(3, 7, 18, calc(0.08 + var(--glass-reflection, 0.5) * 0.1 + var(--glass-surface-density, 0.62) * 0.08));
|
||||
--glass-navbar-live-filter: blur(var(--glass-navbar-blur)) saturate(var(--glass-navbar-saturation))
|
||||
brightness(var(--glass-navbar-brightness));
|
||||
|
||||
border: 0 !important;
|
||||
-webkit-backdrop-filter: var(--glass-navbar-live-filter) !important;
|
||||
backdrop-filter: var(--glass-navbar-live-filter) !important;
|
||||
background: var(--glass-navbar-sheen), var(--glass-navbar-scrim) !important;
|
||||
box-shadow: var(--glass-navbar-shadow) !important;
|
||||
inset-block-start: var(--shell-floating-navbar-inset) !important;
|
||||
inset-inline: var(--shell-floating-navbar-inset) !important;
|
||||
}
|
||||
|
||||
// 只有已确认的 Chromium SVG 能力才替换基础 CSS 滤镜;未就绪时保持同族材质与真实几何。
|
||||
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'][data-glass-navbar-refraction-ready='true'].layout-navbar-floating-eligible.layout-navbar-away-from-top
|
||||
.layout-navbar {
|
||||
--glass-navbar-live-filter: url('#glass-navbar-live-refraction-high') blur(var(--glass-navbar-blur))
|
||||
saturate(var(--glass-navbar-saturation)) brightness(var(--glass-navbar-brightness));
|
||||
}
|
||||
|
||||
html[data-theme='glass'][data-glass-quality='balanced']:is(
|
||||
[data-glass-appearance='clear'],
|
||||
[data-glass-appearance='tinted']
|
||||
)
|
||||
body[data-theme='glass']
|
||||
.layout-wrapper[data-glass-navbar-refraction='chromium'][data-glass-navbar-refraction-ready='true'].layout-navbar-floating-eligible.layout-navbar-away-from-top
|
||||
.layout-navbar {
|
||||
--glass-navbar-live-filter: url('#glass-navbar-live-refraction-balanced') blur(var(--glass-navbar-blur))
|
||||
saturate(var(--glass-navbar-saturation)) brightness(var(--glass-navbar-brightness));
|
||||
}
|
||||
|
||||
// 常驻侧栏保持附着几何,只有同源位移图解码完成后才接管其单独的背景表面。
|
||||
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-navigation-refraction='chromium'][data-glass-sidebar-refraction-ready='true']
|
||||
.layout-vertical-nav:not(.overlay-nav) {
|
||||
--glass-sidebar-live-filter: url('#glass-sidebar-live-refraction-high') blur(1px) saturate(115%)
|
||||
brightness(var(--glass-transmission-brightness));
|
||||
|
||||
-webkit-backdrop-filter: var(--glass-sidebar-live-filter) !important;
|
||||
backdrop-filter: var(--glass-sidebar-live-filter) !important;
|
||||
|
||||
&::before {
|
||||
-webkit-backdrop-filter: none !important;
|
||||
backdrop-filter: none !important;
|
||||
}
|
||||
}
|
||||
|
||||
html[data-theme='glass'][data-glass-quality='balanced']:is(
|
||||
[data-glass-appearance='clear'],
|
||||
[data-glass-appearance='tinted']
|
||||
)
|
||||
body[data-theme='glass']
|
||||
.layout-wrapper[data-glass-navigation-refraction='chromium'][data-glass-sidebar-refraction-ready='true']
|
||||
.layout-vertical-nav:not(.overlay-nav) {
|
||||
--glass-sidebar-live-filter: url('#glass-sidebar-live-refraction-balanced') blur(1px) saturate(115%)
|
||||
brightness(var(--glass-transmission-brightness));
|
||||
|
||||
-webkit-backdrop-filter: var(--glass-sidebar-live-filter) !important;
|
||||
backdrop-filter: var(--glass-sidebar-live-filter) !important;
|
||||
|
||||
&::before {
|
||||
-webkit-backdrop-filter: none !important;
|
||||
backdrop-filter: none !important;
|
||||
}
|
||||
}
|
||||
|
||||
html[data-theme='glass'][data-glass-appearance='tinted']
|
||||
body[data-theme='glass']
|
||||
.layout-wrapper.layout-navbar-floating-eligible.layout-navbar-away-from-top
|
||||
.layout-navbar {
|
||||
background:
|
||||
var(--glass-navbar-sheen),
|
||||
linear-gradient(
|
||||
rgba(var(--glass-material-accent-rgb), var(--glass-navbar-tint)),
|
||||
rgba(var(--glass-material-accent-rgb), calc(var(--glass-navbar-tint) * 0.45))
|
||||
),
|
||||
var(--glass-navbar-scrim) !important;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-transparency: reduce) {
|
||||
html[data-theme='glass'] body[data-theme='glass'] .layout-vertical-nav {
|
||||
--glass-sidebar-live-filter: none !important;
|
||||
}
|
||||
|
||||
html[data-theme='glass']:is([data-glass-appearance='clear'], [data-glass-appearance='tinted'])
|
||||
body[data-theme='glass']
|
||||
.layout-wrapper.layout-navbar-floating-eligible.layout-navbar-away-from-top
|
||||
.layout-navbar {
|
||||
--glass-navbar-live-filter: none !important;
|
||||
|
||||
-webkit-backdrop-filter: none !important;
|
||||
backdrop-filter: none !important;
|
||||
background: rgb(11, 19, 34) !important;
|
||||
background-image: none !important;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
html[data-theme='glass']:is([data-glass-appearance='clear'], [data-glass-appearance='tinted'])
|
||||
body[data-theme='glass']
|
||||
.layout-wrapper.layout-navbar-floating-eligible
|
||||
.layout-navbar,
|
||||
html[data-theme='glass']:is([data-glass-appearance='clear'], [data-glass-appearance='tinted'])
|
||||
body[data-theme='glass']
|
||||
.layout-wrapper.layout-navbar-floating-eligible
|
||||
.navbar-content-container {
|
||||
transition: none !important;
|
||||
}
|
||||
}
|
||||
|
||||
@include glass-v3.surfaces;
|
||||
|
||||
@@ -0,0 +1,194 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
createGlassNavbarDisplacementField,
|
||||
getGlassNavbarOpticalResponse,
|
||||
supportsGlassNavbarLiveRefraction,
|
||||
} from '@/utils/glassNavbarRefraction'
|
||||
|
||||
describe('getGlassNavbarOpticalResponse', () => {
|
||||
it('prioritizes default reading while retaining the horizontal lens', () => {
|
||||
const optics = getGlassNavbarOpticalResponse({ deformation: 48, translation: 48 })
|
||||
expect(optics.translationPx).toBeCloseTo(1.880064)
|
||||
expect(optics.verticalRatio * 24).toBeLessThan(0.02)
|
||||
expect(optics.horizontalRatio * 24).toBeGreaterThan(2.9)
|
||||
})
|
||||
it.each([
|
||||
[0, 0],
|
||||
[50, 2.125],
|
||||
[80, 8.704],
|
||||
[99, 16.495083],
|
||||
[100, 17],
|
||||
])('maps translation %s to the readable curve at %s pixels', (translation, pixels) => {
|
||||
expect(getGlassNavbarOpticalResponse({ deformation: 48, translation }).translationPx).toBeCloseTo(pixels)
|
||||
})
|
||||
it('keeps the midpoint deformation equivalent to the original 12.5% input', () => {
|
||||
const parameters = Object.freeze({ deformation: 50, translation: 50 })
|
||||
const optics = getGlassNavbarOpticalResponse(parameters)
|
||||
expect(optics.horizontalRatio).toBeCloseTo(0.1386328125)
|
||||
expect(optics.verticalRatio).toBeCloseTo(0.000859375)
|
||||
expect(parameters).toEqual({ deformation: 50, translation: 50 })
|
||||
})
|
||||
it('separates translation from deformation and limits the maximum translation', () => {
|
||||
expect(getGlassNavbarOpticalResponse({ deformation: 0, translation: 100 })).toEqual({
|
||||
horizontalRatio: 0,
|
||||
verticalRatio: 0,
|
||||
translationPx: 17,
|
||||
})
|
||||
expect(getGlassNavbarOpticalResponse({ deformation: 100, translation: 0 })).toEqual({
|
||||
horizontalRatio: 0.42,
|
||||
verticalRatio: 0.055,
|
||||
translationPx: 0,
|
||||
})
|
||||
expect(getGlassNavbarOpticalResponse({ deformation: -30, translation: 300 }).translationPx).toBe(17)
|
||||
expect(getGlassNavbarOpticalResponse({ deformation: 48, translation: 99 }).translationPx).toBeGreaterThan(16)
|
||||
expect(getGlassNavbarOpticalResponse({ deformation: 48, translation: 99 }).translationPx).toBeLessThan(17)
|
||||
})
|
||||
})
|
||||
|
||||
describe('createGlassNavbarDisplacementField', () => {
|
||||
function pixelAt(field: ReturnType<typeof createGlassNavbarDisplacementField>, x: number, y: number) {
|
||||
const offset = (y * field.width + x) * 4
|
||||
|
||||
return [...field.pixels.slice(offset, offset + 4)]
|
||||
}
|
||||
|
||||
it('keeps both contour boundary and interior neutral while bending only the narrow rim', () => {
|
||||
const field = createGlassNavbarDisplacementField({
|
||||
height: 41,
|
||||
radius: 12,
|
||||
width: 101,
|
||||
optics: getGlassNavbarOpticalResponse({ deformation: 100, translation: 0 }),
|
||||
})
|
||||
|
||||
expect(field.width).toBe(101)
|
||||
expect(field.height).toBe(41)
|
||||
expect(pixelAt(field, 50, 0)).toEqual([128, 128, 128, 255])
|
||||
expect(pixelAt(field, 50, 20)).toEqual([128, 128, 128, 255])
|
||||
expect(pixelAt(field, 50, 5)[2]).toBeLessThan(128)
|
||||
expect(pixelAt(field, 5, 20)[0]).toBeLessThan(120)
|
||||
expect(pixelAt(field, 95, 20)[0]).toBeGreaterThan(136)
|
||||
expect(pixelAt(field, 50, 35)[2]).toBeGreaterThan(128)
|
||||
})
|
||||
|
||||
it.each([
|
||||
{ width: 1423, height: 64, radius: 16 },
|
||||
{ width: 401, height: 72, radius: 16 },
|
||||
{ width: 127, height: 64, radius: 8 },
|
||||
{ width: 127, height: 64, radius: 32 },
|
||||
{ width: 260, height: 800, radius: 0 },
|
||||
{ width: 68, height: 862, radius: 0 },
|
||||
{ width: 252, height: 846, radius: 16 },
|
||||
{ width: 60, height: 846, radius: 16 },
|
||||
])('keeps two-dimensional sampling forward and inside the image for $width x $height r$radius', geometry => {
|
||||
for (const deformation of [0, 48, 100])
|
||||
for (const translation of [0, 48, 100])
|
||||
for (const scale of [-22, -34]) {
|
||||
const field = createGlassNavbarDisplacementField({
|
||||
...geometry,
|
||||
optics: getGlassNavbarOpticalResponse({ deformation, translation }),
|
||||
})
|
||||
const source = (x: number, y: number) => {
|
||||
const pixel = pixelAt(field, x, y)
|
||||
return [x + 0.5 + scale * (pixel[0] / 255 - 0.5), y + 0.5 + scale * (pixel[2] / 255 - 0.5)]
|
||||
}
|
||||
let minimumDeterminant = Number.POSITIVE_INFINITY
|
||||
let minimumX = Number.POSITIVE_INFINITY
|
||||
let minimumY = Number.POSITIVE_INFINITY
|
||||
let maximumX = 0
|
||||
let maximumY = 0
|
||||
for (let y = 0; y < field.height; y += 1) {
|
||||
for (let x = 0; x < field.width; x += 1) {
|
||||
const point = source(x, y)
|
||||
minimumX = Math.min(minimumX, point[0])
|
||||
minimumY = Math.min(minimumY, point[1])
|
||||
maximumX = Math.max(maximumX, point[0])
|
||||
maximumY = Math.max(maximumY, point[1])
|
||||
if (x === field.width - 1 || y === field.height - 1) continue
|
||||
const nextX = source(x + 1, y)
|
||||
const nextY = source(x, y + 1)
|
||||
const determinant =
|
||||
(nextX[0] - point[0]) * (nextY[1] - point[1]) - (nextY[0] - point[0]) * (nextX[1] - point[1])
|
||||
minimumDeterminant = Math.min(minimumDeterminant, determinant)
|
||||
}
|
||||
}
|
||||
expect(minimumDeterminant).toBeGreaterThan(0.05)
|
||||
expect(minimumX).toBeGreaterThanOrEqual(0)
|
||||
expect(minimumY).toBeGreaterThanOrEqual(0)
|
||||
expect(maximumX).toBeLessThanOrEqual(field.width)
|
||||
expect(maximumY).toBeLessThanOrEqual(field.height)
|
||||
}
|
||||
})
|
||||
|
||||
it('keeps a fixed rectangle optically active without substituting a rounded corner', () => {
|
||||
const field = createGlassNavbarDisplacementField({
|
||||
height: 800,
|
||||
radius: 0,
|
||||
width: 260,
|
||||
optics: getGlassNavbarOpticalResponse({ deformation: 100, translation: 0 }),
|
||||
})
|
||||
|
||||
expect(pixelAt(field, 130, 0)).toEqual([128, 128, 128, 255])
|
||||
expect(pixelAt(field, 130, 8)[2]).toBeLessThan(128)
|
||||
expect(pixelAt(field, 8, 400)[0]).toBeLessThan(128)
|
||||
expect(pixelAt(field, 8, 8)[0]).toBeLessThan(128)
|
||||
expect(pixelAt(field, 8, 8)[2]).toBeLessThan(128)
|
||||
})
|
||||
|
||||
it('clamps invalidly small geometry to a renderable pixel surface', () => {
|
||||
const field = createGlassNavbarDisplacementField({ height: 0, radius: 20, width: -10 })
|
||||
|
||||
expect(field.width).toBe(1)
|
||||
expect(field.height).toBe(1)
|
||||
expect([...field.pixels]).toEqual([128, 128, 128, 255])
|
||||
})
|
||||
|
||||
it('limits vertical text stretching without flattening the horizontal lens', () => {
|
||||
const field = createGlassNavbarDisplacementField({ width: 1423, height: 64, radius: 16 })
|
||||
const displacement = (x: number, y: number, channel: number) => -34 * (pixelAt(field, x, y)[channel] / 255 - 0.5)
|
||||
let maximumHorizontal = 0
|
||||
let maximumVertical = 0
|
||||
let minimumVerticalStep = Infinity
|
||||
let maximumVerticalStep = 0
|
||||
for (let y = 1; y < field.height; y += 1) {
|
||||
const previous = displacement(711, y - 1, 2)
|
||||
const current = displacement(711, y, 2)
|
||||
maximumVertical = Math.max(maximumVertical, Math.abs(current))
|
||||
minimumVerticalStep = Math.min(minimumVerticalStep, 1 + current - previous)
|
||||
maximumVerticalStep = Math.max(maximumVerticalStep, 1 + current - previous)
|
||||
}
|
||||
for (let x = 0; x < 32; x += 1) maximumHorizontal = Math.max(maximumHorizontal, Math.abs(displacement(x, 32, 0)))
|
||||
expect(maximumHorizontal).toBeGreaterThan(1.5)
|
||||
expect(maximumVertical).toBeLessThan(0.5)
|
||||
expect(minimumVerticalStep).toBeGreaterThan(0.85)
|
||||
expect(maximumVerticalStep).toBeLessThan(1.15)
|
||||
})
|
||||
})
|
||||
|
||||
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)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,224 @@
|
||||
import {
|
||||
getGlassOpticalPresetParameters,
|
||||
normalizeGlassOpticalStrength,
|
||||
type GlassOpticalParameters,
|
||||
} from '@/utils/glassOptics'
|
||||
|
||||
export interface GlassNavbarRefractionBrowserIdentity {
|
||||
/** User-Agent Client Hints 暴露的浏览器品牌。 */
|
||||
userAgentData?: {
|
||||
brands?: readonly {
|
||||
brand: string
|
||||
}[]
|
||||
}
|
||||
/** Client Hints 不可用时使用的传统浏览器标识。 */
|
||||
userAgent: string
|
||||
}
|
||||
|
||||
export interface GlassNavbarDisplacementGeometry {
|
||||
/** 折射表面的实际 CSS 像素高度。 */
|
||||
height: number
|
||||
/** 最终可见外轮廓的圆角半径。 */
|
||||
radius: number
|
||||
/** 折射表面的实际 CSS 像素宽度。 */
|
||||
width: number
|
||||
/** 由当前生效滑杆计算的顶栏光学响应;省略时采用清透自然默认值。 */
|
||||
optics?: GlassNavbarOpticalResponse
|
||||
}
|
||||
|
||||
/** 顶栏局部取样预算,不包含共享 renderer 的流动、尾波与惯性。 */
|
||||
export interface GlassNavbarOpticalResponse {
|
||||
/** 横向边缘峰值位移与轮廓带宽之比。 */
|
||||
horizontalRatio: number
|
||||
/** 纵向峰值位移与轮廓带宽之比,严格小于横向以保护字形高度。 */
|
||||
verticalRatio: number
|
||||
/** 主体内容统一向右显示的 CSS 像素偏移,外轮廓平缓回零。 */
|
||||
translationPx: number
|
||||
}
|
||||
|
||||
/** 导航以低中段可读性为优先,高段保留完整位移预算;不改写共享参数或材质响应。 */
|
||||
export function getGlassNavbarOpticalResponse(
|
||||
parameters: Pick<GlassOpticalParameters, 'deformation' | 'translation'>,
|
||||
): GlassNavbarOpticalResponse {
|
||||
// 两个滑杆分别映射,50% 均使用 12.5% 的光学输入,避免平移维持高强度而抵消阅读改善。
|
||||
const deformation = (normalizeGlassOpticalStrength(parameters.deformation) / 100) ** 3
|
||||
const translation = (normalizeGlassOpticalStrength(parameters.translation) / 100) ** 3
|
||||
return {
|
||||
horizontalRatio: 0.42 * (1 - (1 - deformation) ** 3),
|
||||
verticalRatio: 0.055 * deformation ** 2,
|
||||
translationPx: 17 * translation,
|
||||
}
|
||||
}
|
||||
|
||||
export interface GlassNavbarDisplacementField {
|
||||
/** 位移图的 CSS 像素高度。 */
|
||||
height: number
|
||||
/** 按 RGBA 顺序存储的非预乘像素通道。 */
|
||||
pixels: Uint8ClampedArray
|
||||
/** 位移图的 CSS 像素宽度。 */
|
||||
width: number
|
||||
}
|
||||
|
||||
export const NEUTRAL_GLASS_NAVBAR_DISPLACEMENT_MAP =
|
||||
'data:image/svg+xml,%3Csvg xmlns="http://www.w3.org/2000/svg" width="1" height="1"%3E%3Cpath fill="%23808080" d="M0 0h1v1H0z"/%3E%3C/svg%3E'
|
||||
|
||||
const DISPLACEMENT_NEUTRAL_CHANNEL = 128
|
||||
const HIGH_REFRACTION_SCALE_PX = 34
|
||||
const OUTER_NEUTRAL_GUARD_PX = 0.5
|
||||
const REFRACTION_BAND_PX = 24
|
||||
// 峰值靠近外沿,内侧有足够距离释放放大率;对称波峰会在窄轮廓内反向采样。
|
||||
const PEAK_DEPTH_RATIO = 0.16
|
||||
const DEFAULT_NAVBAR_OPTICS = getGlassNavbarOpticalResponse(getGlassOpticalPresetParameters('clear', 'high', 'natural'))
|
||||
|
||||
function normalizePixelSize(value: number) {
|
||||
return Number.isFinite(value) ? Math.max(1, Math.round(value)) : 1
|
||||
}
|
||||
|
||||
function roundedRectangleSignedDistance(x: number, y: number, width: number, height: number, radius: number) {
|
||||
const offsetX = Math.abs(x - width / 2) - (width / 2 - radius)
|
||||
const offsetY = Math.abs(y - height / 2) - (height / 2 - radius)
|
||||
const outsideX = Math.max(offsetX, 0)
|
||||
const outsideY = Math.max(offsetY, 0)
|
||||
|
||||
return Math.hypot(outsideX, outsideY) + Math.min(Math.max(offsetX, offsetY), 0) - radius
|
||||
}
|
||||
|
||||
function clampChannel(value: number) {
|
||||
return Math.max(0, Math.min(255, Math.round(value)))
|
||||
}
|
||||
|
||||
function smoothstep(value: number) {
|
||||
return value * value * (3 - 2 * value)
|
||||
}
|
||||
|
||||
/** 外侧快速形成厚度,内侧缓慢回到中性,保留清透中心且不折返背景。 */
|
||||
function refractionProfile(depth: number, band: number, guard: number) {
|
||||
if (depth <= guard || depth >= band) return 0
|
||||
|
||||
const peakDepth = Math.max(guard, band * PEAK_DEPTH_RATIO)
|
||||
if (depth <= peakDepth) return smoothstep((depth - guard) / (peakDepth - guard || 1))
|
||||
return 1 - smoothstep((depth - peakDepth) / (band - peakDepth))
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成圆角表面的法线位移场。
|
||||
* 外轮廓回到中性;内部统一平移与局部透镜分开计算,不用弯曲字形代替平移。
|
||||
*/
|
||||
export function createGlassNavbarDisplacementField({
|
||||
height,
|
||||
radius,
|
||||
width,
|
||||
optics = DEFAULT_NAVBAR_OPTICS,
|
||||
}: GlassNavbarDisplacementGeometry): GlassNavbarDisplacementField {
|
||||
const pixelWidth = normalizePixelSize(width)
|
||||
const pixelHeight = normalizePixelSize(height)
|
||||
const maxRadius = Math.min(pixelWidth, pixelHeight) / 2
|
||||
const pixelRadius = Number.isFinite(radius) ? Math.max(0, Math.min(maxRadius, radius)) : 0
|
||||
// 直角固定表面没有圆角半径可供推导,仍使用受最短边约束的直边带;radius=0 不能被当成无折射。
|
||||
const radiusBand = pixelRadius > 0 ? pixelRadius * 1.5 : REFRACTION_BAND_PX
|
||||
const maximumBand = Math.min(REFRACTION_BAND_PX, radiusBand, Math.min(pixelWidth, pixelHeight) / 2)
|
||||
const pixels = new Uint8ClampedArray(pixelWidth * pixelHeight * 4)
|
||||
|
||||
for (let offset = 0; offset < pixels.length; offset += 4) {
|
||||
pixels[offset] = DISPLACEMENT_NEUTRAL_CHANNEL
|
||||
pixels[offset + 1] = DISPLACEMENT_NEUTRAL_CHANNEL
|
||||
pixels[offset + 2] = DISPLACEMENT_NEUTRAL_CHANNEL
|
||||
pixels[offset + 3] = 255
|
||||
}
|
||||
|
||||
for (let y = 0; y < pixelHeight; y += 1) {
|
||||
for (let x = 0; x < pixelWidth; x += 1) {
|
||||
const sampleX = x + 0.5
|
||||
const sampleY = y + 0.5
|
||||
const signedDistance = roundedRectangleSignedDistance(sampleX, sampleY, pixelWidth, pixelHeight, pixelRadius)
|
||||
const distanceInside = -signedDistance
|
||||
// 长直边允许更厚的透镜;向圆角与法线交汇轴渐缩,避免高曲率区产生聚焦尖点。
|
||||
const edgeX = Math.min(sampleX, pixelWidth - sampleX)
|
||||
const edgeY = Math.min(sampleY, pixelHeight - sampleY)
|
||||
const straightWeight = smoothstep(Math.min(1, Math.abs(edgeX - edgeY) / (maximumBand * 2 || 1)))
|
||||
const cornerBand = Math.min(maximumBand, pixelRadius)
|
||||
// 矩形角点没有圆弧法线;固定带宽交给四条直边的轴向剖面处理,避免角点成为采样断点。
|
||||
const bandWidth = pixelRadius === 0 ? maximumBand : cornerBand + (maximumBand - cornerBand) * straightWeight
|
||||
const outerGuard = Math.min(OUTER_NEUTRAL_GUARD_PX, bandWidth / 4)
|
||||
const channelAmplitude = (bandWidth * optics.horizontalRatio * 255) / HIGH_REFRACTION_SCALE_PX
|
||||
|
||||
if (
|
||||
signedDistance > 0 ||
|
||||
distanceInside <= outerGuard ||
|
||||
bandWidth <= outerGuard ||
|
||||
Math.min(pixelWidth, pixelHeight) < 4
|
||||
)
|
||||
continue
|
||||
|
||||
// 横向平移从边缘透镜退出后进入,避免两种回落梯度叠加导致局部反向采样。
|
||||
const horizontalRamp = smoothstep(Math.max(0, Math.min(1, (edgeX - maximumBand) / 64)))
|
||||
const verticalRamp = smoothstep(Math.min(1, (edgeY - outerGuard) / Math.max(1, Math.min(12, maximumBand))))
|
||||
const translationChannel = (optics.translationPx * horizontalRamp * verticalRamp * 255) / HIGH_REFRACTION_SCALE_PX
|
||||
|
||||
if (pixelRadius === 0) {
|
||||
// 矩形的四条直边分别取样,角点用叠加的轴向剖面保持连续,不伪造圆角法线。
|
||||
const leftProfile = refractionProfile(sampleX, maximumBand, outerGuard)
|
||||
const rightProfile = refractionProfile(pixelWidth - sampleX, maximumBand, outerGuard)
|
||||
const topProfile = refractionProfile(sampleY, maximumBand, outerGuard)
|
||||
const bottomProfile = refractionProfile(pixelHeight - sampleY, maximumBand, outerGuard)
|
||||
const verticalAmplitude = (maximumBand * optics.verticalRatio * 255) / HIGH_REFRACTION_SCALE_PX
|
||||
const offset = (y * pixelWidth + x) * 4
|
||||
|
||||
pixels[offset] = clampChannel(
|
||||
DISPLACEMENT_NEUTRAL_CHANNEL + channelAmplitude * (rightProfile - leftProfile) + translationChannel,
|
||||
)
|
||||
pixels[offset + 2] = clampChannel(
|
||||
DISPLACEMENT_NEUTRAL_CHANNEL + verticalAmplitude * (bottomProfile - topProfile),
|
||||
)
|
||||
continue
|
||||
}
|
||||
|
||||
const profile = distanceInside < bandWidth ? refractionProfile(distanceInside, bandWidth, outerGuard) : 0
|
||||
const verticalProgress = Math.min(1, (distanceInside - outerGuard) / (bandWidth - outerGuard))
|
||||
const verticalProfile = Math.sin(Math.PI * verticalProgress) ** 2
|
||||
const verticalAmplitude = (bandWidth * optics.verticalRatio * 255) / HIGH_REFRACTION_SCALE_PX
|
||||
const gradientX =
|
||||
roundedRectangleSignedDistance(sampleX + 0.5, sampleY, pixelWidth, pixelHeight, pixelRadius) -
|
||||
roundedRectangleSignedDistance(sampleX - 0.5, sampleY, pixelWidth, pixelHeight, pixelRadius)
|
||||
const gradientY =
|
||||
roundedRectangleSignedDistance(sampleX, sampleY + 0.5, pixelWidth, pixelHeight, pixelRadius) -
|
||||
roundedRectangleSignedDistance(sampleX, sampleY - 0.5, pixelWidth, pixelHeight, pixelRadius)
|
||||
const gradientLength = Math.hypot(gradientX, gradientY) || 1
|
||||
const offset = (y * pixelWidth + x) * 4
|
||||
pixels[offset] = clampChannel(
|
||||
DISPLACEMENT_NEUTRAL_CHANNEL + (channelAmplitude * gradientX * profile) / gradientLength + translationChannel,
|
||||
)
|
||||
pixels[offset + 2] = clampChannel(
|
||||
DISPLACEMENT_NEUTRAL_CHANNEL + (verticalAmplitude * gradientY * verticalProfile) / gradientLength,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
return { height: pixelHeight, pixels, width: pixelWidth }
|
||||
}
|
||||
|
||||
/** 把位移场栅格化为浏览器可直接加载的无损 PNG。 */
|
||||
export function createGlassNavbarDisplacementMap(geometry: GlassNavbarDisplacementGeometry) {
|
||||
const field = createGlassNavbarDisplacementField(geometry)
|
||||
const canvas = document.createElement('canvas')
|
||||
const context = canvas.getContext('2d')
|
||||
|
||||
if (!context) return NEUTRAL_GLASS_NAVBAR_DISPLACEMENT_MAP
|
||||
|
||||
canvas.width = field.width
|
||||
canvas.height = field.height
|
||||
const imageData = context.createImageData(field.width, field.height)
|
||||
|
||||
imageData.data.set(field.pixels)
|
||||
context.putImageData(imageData, 0, 0)
|
||||
|
||||
return canvas.toDataURL('image/png')
|
||||
}
|
||||
|
||||
/** 仅在已验证 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)
|
||||
}
|
||||
Reference in New Issue
Block a user