Merge pull request #755 from InfinityPacer/codex/poc/liquid-glass-navbar-5050

This commit is contained in:
InfinityPacer
2026-09-06 18:21:04 +08:00
committed by GitHub
15 changed files with 3677 additions and 191 deletions
-11
View File
@@ -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
+27 -3
View File
@@ -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
View File
@@ -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
+128 -80
View File
@@ -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);
previous = texture2DGradEXT(
uPreviousFrostedTexture,
previousUv,
dFdx(previousUv) * frostGradientScale,
dFdy(previousUv) * frostGradientScale
).rgb;
current = texture2DGradEXT(
uFrostedTexture,
uv,
dFdx(uv) * frostGradientScale,
dFdy(uv) * frostGradientScale
).rgb;
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,25 +936,23 @@ ${GLASS_FLUID_FRAGMENT_SURFACE_REFRACTION}
? refracted
: sampleChromatic(sourceUv, detailSeparation);
refracted = mix(refracted, detailed, mix(0.06, 0.16, uQuality) * (1.0 - frosted));
vec2 diffusionAxis = length(refraction) > 0.00001 ? normalize(refraction) : wakePerpendicular;
float diffusionRadius =
mix(0.0022, 0.0038, uQuality) *
(
0.82 +
materialEnergy * mix(0.28, 0.76, uMotionExpansion) +
flowSurfaceDetail * dynamicMask * 0.38
);
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);
// 非磨砂不使用扩散结果;预滤磨砂已具备低通纹理,两者都无需额外邻域采样。
if (frosted > 0.5 && usesPrefilteredFrost <= 0.5) {
vec2 diffusionAxis = length(refraction) > 0.00001 ? normalize(refraction) : wakePerpendicular;
float diffusionRadius =
mix(0.0022, 0.0038, uQuality) *
(
0.82 +
materialEnergy * mix(0.28, 0.76, uMotionExpansion) +
flowSurfaceDetail * dynamicMask * 0.38
);
float frostedDensity = frosted * (1.0 - uFrostDetailLevel);
diffusionRadius *= 1.0 + frostedDensity * mix(1.15, 1.55, uQuality);
vec3 diffused = uQuality > 0.5
? sampleHighQualityDiffuse(sourceUv, diffusionAxis, diffusionRadius)
: sampleBalancedDiffuse(sourceUv, diffusionAxis, diffusionRadius);
refracted = mix(refracted, diffused, frosted);
}
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')
})
})
+309
View File
@@ -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;
}
}
}
}
+283 -2
View File
@@ -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)
})
})
+224
View File
@@ -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)
}