feat(glass): extend readable optics and frost to desktop navigation

This commit is contained in:
InfinityPacer
2026-09-06 04:57:30 +08:00
parent 5c853f4fa9
commit 70867f6133
7 changed files with 616 additions and 128 deletions
@@ -211,6 +211,7 @@ export default defineComponent({
? 'theme-qualified' ? 'theme-qualified'
: 'connected', : 'connected',
'data-shell-scroll-direction': shellScroll.direction.value, 'data-shell-scroll-direction': shellScroll.direction.value,
'data-glass-navigation-refraction': navbarRefractionMode,
'data-glass-navbar-refraction': navbarRefractionMode, 'data-glass-navbar-refraction': navbarRefractionMode,
style: { style: {
'--layout-footer-dock-height': `${footerDockHeight.value ?? 0}px`, '--layout-footer-dock-height': `${footerDockHeight.value ?? 0}px`,
@@ -1,4 +1,5 @@
<script lang="ts" setup> <script lang="ts" setup>
import type { Ref } from 'vue'
import { import {
createGlassNavbarDisplacementMap, createGlassNavbarDisplacementMap,
getGlassNavbarOpticalResponse, getGlassNavbarOpticalResponse,
@@ -6,6 +7,42 @@ import {
} from '@/utils/glassNavbarRefraction' } from '@/utils/glassNavbarRefraction'
import { useEffectiveGlassSettings } from '@/composables/useThemeCustomizer' 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 settings = useEffectiveGlassSettings()
const opticalResponse = computed(() => const opticalResponse = computed(() =>
getGlassNavbarOpticalResponse({ getGlassNavbarOpticalResponse({
@@ -19,6 +56,11 @@ const DEFAULT_NAVBAR_GEOMETRY = {
radius: 16, radius: 16,
width: 1200, width: 1200,
} }
const DEFAULT_SIDEBAR_GEOMETRY = {
height: 800,
radius: 0,
width: 260,
}
const MAP_RESIZE_SETTLE_MS = 60 const MAP_RESIZE_SETTLE_MS = 60
const OBSERVED_SIZE_STYLE_PROPERTIES = [ const OBSERVED_SIZE_STYLE_PROPERTIES = [
'--shell-floating-navbar-radius', '--shell-floating-navbar-radius',
@@ -26,6 +68,8 @@ const OBSERVED_SIZE_STYLE_PROPERTIES = [
'--layout-navbar-block-size', '--layout-navbar-block-size',
'--layout-navbar-safe-area-top', '--layout-navbar-safe-area-top',
'--navbar-tab-height', '--navbar-tab-height',
'--layout-vertical-nav-width',
'--layout-vertical-nav-collapsed-width',
'border-radius', 'border-radius',
'border-start-start-radius', 'border-start-start-radius',
'width', 'width',
@@ -33,45 +77,104 @@ const OBSERVED_SIZE_STYLE_PROPERTIES = [
'inline-size', 'inline-size',
'block-size', 'block-size',
] ]
const displacementMapUrl = ref(NEUTRAL_GLASS_NAVBAR_DISPLACEMENT_MAP) const navbarMapUrl = ref(NEUTRAL_GLASS_NAVBAR_DISPLACEMENT_MAP)
const displacementMapSize = reactive({ const sidebarMapUrl = ref(NEUTRAL_GLASS_NAVBAR_DISPLACEMENT_MAP)
const navbarMapSize = reactive({
height: DEFAULT_NAVBAR_GEOMETRY.height, height: DEFAULT_NAVBAR_GEOMETRY.height,
width: DEFAULT_NAVBAR_GEOMETRY.width, width: DEFAULT_NAVBAR_GEOMETRY.width,
}) })
const sidebarMapSize = reactive({
height: DEFAULT_SIDEBAR_GEOMETRY.height,
width: DEFAULT_SIDEBAR_GEOMETRY.width,
})
let observedNavbar: HTMLElement | null = null
let observedShell: HTMLElement | null = null let observedShell: HTMLElement | null = null
let resizeObserver: ResizeObserver | null = null let resizeObserver: ResizeObserver | null = null
let stateObserver: MutationObserver | null = null let stateObserver: MutationObserver | null = null
let resizeTimer: ReturnType<typeof setTimeout> | null = null let resizeTimer: ReturnType<typeof setTimeout> | null = null
let transparencyQuery: MediaQueryList | null = null let transparencyQuery: MediaQueryList | null = null
let mapRevision = 0 const navbarState: NavigationSurfaceState = {
let cachedGeometry = '' cachedGeometry: '',
let cachedMap = NEUTRAL_GLASS_NAVBAR_DISPLACEMENT_MAP cachedMap: NEUTRAL_GLASS_NAVBAR_DISPLACEMENT_MAP,
let pendingGeometry = '' defaultGeometry: DEFAULT_NAVBAR_GEOMETRY,
let failedGeometry = '' element: null,
let lastObservedGeometry = '' failedGeometry: '',
const geometryTransitions = new Set<string>() 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,
}
/** CSS 档、非水平浮动态与无障碍回退不生成或启用位移图。 */ function getSurfaceState(surface: NavigationSurface) {
function isRefractionActive() { return surfaceStates[surface]
}
/** 只有桌面清透/色调导航进入 SVG 位移增强;磨砂固定层沿用稳定背板或原生 CSS。 */
function isRefractionActive(surface: NavigationSurface) {
const { theme, glassAppearance, glassQuality } = document.documentElement.dataset 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.classList.contains('layout-navbar-floating-eligible') &&
shell.classList.contains('layout-navbar-away-from-top')
)
}
return ( return (
theme === 'glass' && !element.classList.contains('overlay-nav') &&
(glassAppearance === 'clear' || glassAppearance === 'tinted') && !shell.classList.contains('layout-overlay-nav') &&
(glassQuality === 'balanced' || glassQuality === 'high') && !shell.classList.contains('layout-app-shell') &&
observedShell?.classList.contains('layout-navbar-floating-eligible') && !shell.classList.contains('layout-horizontal-nav-active')
observedShell.classList.contains('layout-navbar-away-from-top') &&
!transparencyQuery?.matches
) )
} }
/** 几何或状态变化后立即撤销 map,避免另一个尺寸中采样。 */ /** 几何或状态变化后立即撤销对应 map,避免另一个尺寸继续采样旧位移场。 */
function invalidateDisplacementMap() { function invalidateDisplacementMap(surface: NavigationSurface) {
mapRevision += 1 const state = getSurfaceState(surface)
pendingGeometry = '' state.mapRevision += 1
observedShell?.setAttribute('data-glass-navbar-refraction-ready', 'false') state.pendingGeometry = ''
state.shell?.setAttribute(state.readyAttribute, 'false')
} }
function getInlineStyleValue(styleText: string | null, property: string) { function getInlineStyleValue(styleText: string | null, property: string) {
@@ -96,17 +199,18 @@ function handleStateMutations(records: MutationRecord[]) {
if (records.some(hasObservedSizeStyleChange)) scheduleDisplacementMapSync() if (records.some(hasObservedSizeStyleChange)) scheduleDisplacementMapSync()
} }
/** 以真实边界和计算后的圆角统一比较已解码、待解码与当前采样几何。 */ /** 读取真实表面边界;圆角使用计算后的 CSS 像素,矩形侧栏明确传入 0。 */
function readDisplacementGeometry() { function readDisplacementGeometry(surface: NavigationSurface) {
if (!observedNavbar) return null const state = getSurfaceState(surface)
const bounds = observedNavbar.getBoundingClientRect() if (!state.element) return null
const styles = getComputedStyle(observedNavbar) const bounds = state.element.getBoundingClientRect()
const styles = getComputedStyle(state.element)
// 自定义属性可能保留 rem;只有计算后的圆角与位移图使用同一 CSS 像素坐标。 // 自定义属性可能保留 rem;只有计算后的圆角与位移图使用同一 CSS 像素坐标。
const borderRadius = Number.parseFloat(styles.borderStartStartRadius) const borderRadius = Number.parseFloat(styles.borderStartStartRadius)
const height = Math.max(1, Math.round(bounds.height)) const height = Math.max(1, Math.round(bounds.height))
const width = Math.max(1, Math.round(bounds.width)) const width = Math.max(1, Math.round(bounds.width))
const radius = Number.isFinite(borderRadius) ? borderRadius : DEFAULT_NAVBAR_GEOMETRY.radius const radius = Number.isFinite(borderRadius) ? borderRadius : state.defaultGeometry.radius
const optics = opticalResponse.value const optics = opticalResponse.value
return { return {
height, height,
@@ -118,92 +222,107 @@ function readDisplacementGeometry() {
} }
/** map 与 feImage 尺寸同批更新;解码失败或过期结果继续使用 CSS 材质。 */ /** map 与 feImage 尺寸同批更新;解码失败或过期结果继续使用 CSS 材质。 */
async function syncDisplacementMap() { async function syncDisplacementMap(surface: NavigationSurface) {
if (!isRefractionActive() || geometryTransitions.size > 0) return const state = getSurfaceState(surface)
const geometry = readDisplacementGeometry() if (!isRefractionActive(surface) || state.geometryTransitions.size > 0) return
if (!geometry || pendingGeometry === geometry.key) return const geometry = readDisplacementGeometry(surface)
if (!geometry || state.pendingGeometry === geometry.key) return
const { height, radius, width, optics, key: geometryKey } = geometry const { height, radius, width, optics, key: geometryKey } = geometry
const revision = ++mapRevision const revision = ++state.mapRevision
pendingGeometry = geometryKey state.pendingGeometry = geometryKey
if (lastObservedGeometry !== geometryKey) { if (state.lastObservedGeometry !== geometryKey) {
lastObservedGeometry = geometryKey state.lastObservedGeometry = geometryKey
failedGeometry = '' state.failedGeometry = ''
} }
try { try {
if (cachedGeometry !== geometryKey) { if (state.cachedGeometry !== geometryKey) {
if (failedGeometry === geometryKey) return if (state.failedGeometry === geometryKey) return
const map = createGlassNavbarDisplacementMap({ height, radius, width, optics }) const map = createGlassNavbarDisplacementMap({ height, radius, width, optics })
if (map === NEUTRAL_GLASS_NAVBAR_DISPLACEMENT_MAP) { if (map === NEUTRAL_GLASS_NAVBAR_DISPLACEMENT_MAP) {
failedGeometry = geometryKey state.failedGeometry = geometryKey
invalidateDisplacementMap() invalidateDisplacementMap(surface)
return return
} }
const decoded = new Image() const decoded = new Image()
decoded.src = map decoded.src = map
await decoded.decode() await decoded.decode()
if (revision !== mapRevision || !isRefractionActive()) return if (revision !== state.mapRevision || !isRefractionActive(surface)) return
cachedGeometry = geometryKey state.cachedGeometry = geometryKey
cachedMap = map state.cachedMap = map
failedGeometry = '' state.failedGeometry = ''
} }
displacementMapSize.height = height state.mapSize.height = height
displacementMapSize.width = width state.mapSize.width = width
displacementMapUrl.value = cachedMap state.mapUrl.value = state.cachedMap
await nextTick() await nextTick()
if (revision === mapRevision && isRefractionActive()) { if (revision === state.mapRevision && isRefractionActive(surface)) {
observedShell?.setAttribute('data-glass-navbar-refraction-ready', 'true') state.shell?.setAttribute(state.readyAttribute, 'true')
} }
} catch { } catch {
// 位移是增强能力;图片解码失败不阻断导航和原生玻璃表面。 // 位移是增强能力;图片解码失败不阻断导航和原生玻璃表面。
if (revision === mapRevision) { if (revision === state.mapRevision) {
failedGeometry = geometryKey state.failedGeometry = geometryKey
invalidateDisplacementMap() invalidateDisplacementMap(surface)
} }
} finally { } finally {
if (revision === mapRevision) pendingGeometry = '' if (revision === state.mapRevision) state.pendingGeometry = ''
} }
} }
function syncDisplacementMaps() {
for (const surface of SURFACE_KEYS) void syncDisplacementMap(surface)
}
// 连续 resize 需要合并;相同几何的通知不撤销已就绪或正在解码的位移图。 // 连续 resize 需要合并;相同几何的通知不撤销已就绪或正在解码的位移图。
function scheduleDisplacementMapSync() { function scheduleDisplacementMapSync() {
if (resizeTimer !== null) clearTimeout(resizeTimer) if (resizeTimer !== null) clearTimeout(resizeTimer)
resizeTimer = null resizeTimer = null
if (!isRefractionActive() || geometryTransitions.size > 0) {
invalidateDisplacementMap() let shouldSync = false
return 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()
if (geometry?.key === pendingGeometry) return const geometry = readDisplacementGeometry(surface)
if (geometry?.key === cachedGeometry && displacementMapUrl.value === cachedMap) { if (!geometry || geometry.key === state.pendingGeometry) continue
if (geometry.key === state.cachedGeometry && state.mapUrl.value === state.cachedMap) {
// 取消草稿可能命中旧缓存,同时还有另一参数的解码;先使该异步结果失效。 // 取消草稿可能命中旧缓存,同时还有另一参数的解码;先使该异步结果失效。
if (pendingGeometry) invalidateDisplacementMap() if (state.pendingGeometry) invalidateDisplacementMap(surface)
observedShell?.setAttribute('data-glass-navbar-refraction-ready', 'true') state.shell?.setAttribute(state.readyAttribute, 'true')
return continue
} }
invalidateDisplacementMap() invalidateDisplacementMap(surface)
shouldSync = true
}
if (!shouldSync) return
resizeTimer = setTimeout(() => { resizeTimer = setTimeout(() => {
resizeTimer = null resizeTimer = null
void syncDisplacementMap() syncDisplacementMaps()
}, MAP_RESIZE_SETTLE_MS) }, MAP_RESIZE_SETTLE_MS)
} }
function handleGeometryTransition(event: TransitionEvent) { function handleGeometryTransition(surface: NavigationSurface, event: TransitionEvent) {
const state = getSurfaceState(surface)
if ( if (
event.target !== observedNavbar || event.target !== state.element ||
!/^(inset|top|left|right|width|height|inline-size|block-size|border.*radius)/u.test(event.propertyName) !/^(inset|top|left|right|width|height|inline-size|block-size|border.*radius)/u.test(event.propertyName)
) )
return return
if (event.type === 'transitionrun') { if (event.type === 'transitionrun') {
geometryTransitions.add(event.propertyName) state.geometryTransitions.add(event.propertyName)
invalidateDisplacementMap() invalidateDisplacementMap(surface)
} else { } else {
geometryTransitions.delete(event.propertyName) state.geometryTransitions.delete(event.propertyName)
// transitionend/cancel 已给出稳定尺寸,无需再附加 resize 防抖等待。 // transitionend/cancel 已给出稳定尺寸,无需再附加 resize 防抖等待。
if (geometryTransitions.size === 0) { if (state.geometryTransitions.size === 0) {
if (resizeTimer !== null) clearTimeout(resizeTimer) if (resizeTimer !== null) clearTimeout(resizeTimer)
resizeTimer = null resizeTimer = null
void syncDisplacementMap() syncDisplacementMaps()
} }
} }
} }
@@ -212,9 +331,16 @@ function handleGeometryTransition(event: TransitionEvent) {
watch(opticalResponse, scheduleDisplacementMapSync, { flush: 'sync' }) watch(opticalResponse, scheduleDisplacementMapSync, { flush: 'sync' })
onMounted(() => { onMounted(() => {
observedNavbar = document.querySelector('.layout-wrapper[data-glass-navbar-refraction="chromium"] .layout-navbar') observedShell =
if (!observedNavbar) return document.querySelector<HTMLElement>('.layout-wrapper[data-glass-navigation-refraction="chromium"]') ??
observedShell = observedNavbar.closest('.layout-wrapper') 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 = window.matchMedia('(prefers-reduced-transparency: reduce)')
transparencyQuery.addEventListener('change', scheduleDisplacementMapSync) transparencyQuery.addEventListener('change', scheduleDisplacementMapSync)
stateObserver = new MutationObserver(handleStateMutations) stateObserver = new MutationObserver(handleStateMutations)
@@ -223,21 +349,26 @@ onMounted(() => {
attributeOldValue: true, attributeOldValue: true,
attributeFilter: ['class', 'style', 'data-theme', 'data-glass-appearance', 'data-glass-quality'], attributeFilter: ['class', 'style', 'data-theme', 'data-glass-appearance', 'data-glass-quality'],
}) })
if (observedShell) {
stateObserver.observe(observedShell, { stateObserver.observe(observedShell, {
attributes: true, attributes: true,
attributeOldValue: true, attributeOldValue: true,
attributeFilter: ['class', 'style'], attributeFilter: ['class', 'style'],
}) })
} for (const surface of SURFACE_KEYS) {
stateObserver.observe(observedNavbar, { const state = getSurfaceState(surface)
const element = state.element
if (!element) continue
stateObserver.observe(element, {
attributes: true, attributes: true,
attributeOldValue: true, attributeOldValue: true,
attributeFilter: ['class', 'style'], attributeFilter: ['class', 'style'],
}) })
observedNavbar.addEventListener('transitionrun', handleGeometryTransition) const transitionHandler: EventListener = event => handleGeometryTransition(surface, event as TransitionEvent)
observedNavbar.addEventListener('transitionend', handleGeometryTransition) transitionHandlers[surface] = transitionHandler
observedNavbar.addEventListener('transitioncancel', handleGeometryTransition) element.addEventListener('transitionrun', transitionHandler)
element.addEventListener('transitionend', transitionHandler)
element.addEventListener('transitioncancel', transitionHandler)
}
scheduleDisplacementMapSync() scheduleDisplacementMapSync()
if (typeof ResizeObserver === 'undefined') { if (typeof ResizeObserver === 'undefined') {
@@ -247,12 +378,27 @@ onMounted(() => {
} }
resizeObserver = new ResizeObserver(scheduleDisplacementMapSync) resizeObserver = new ResizeObserver(scheduleDisplacementMapSync)
resizeObserver.observe(observedNavbar) for (const surface of SURFACE_KEYS) {
const element = getSurfaceState(surface).element
if (element) resizeObserver.observe(element)
}
}) })
onBeforeUnmount(() => { onBeforeUnmount(() => {
invalidateDisplacementMap() for (const surface of SURFACE_KEYS) {
geometryTransitions.clear() 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) if (resizeTimer !== null) clearTimeout(resizeTimer)
resizeTimer = null resizeTimer = null
resizeObserver?.disconnect() resizeObserver?.disconnect()
@@ -261,12 +407,7 @@ onBeforeUnmount(() => {
stateObserver = null stateObserver = null
transparencyQuery?.removeEventListener('change', scheduleDisplacementMapSync) transparencyQuery?.removeEventListener('change', scheduleDisplacementMapSync)
transparencyQuery = null transparencyQuery = null
observedNavbar?.removeEventListener('transitionrun', handleGeometryTransition)
observedNavbar?.removeEventListener('transitionend', handleGeometryTransition)
observedNavbar?.removeEventListener('transitioncancel', handleGeometryTransition)
observedShell?.removeAttribute('data-glass-navbar-refraction-ready')
window.removeEventListener('resize', scheduleDisplacementMapSync) window.removeEventListener('resize', scheduleDisplacementMapSync)
observedNavbar = null
observedShell = null observedShell = null
}) })
</script> </script>
@@ -285,10 +426,10 @@ onBeforeUnmount(() => {
<feImage <feImage
x="0" x="0"
y="0" y="0"
:width="displacementMapSize.width" :width="navbarMapSize.width"
:height="displacementMapSize.height" :height="navbarMapSize.height"
preserveAspectRatio="none" preserveAspectRatio="none"
:href="displacementMapUrl" :href="navbarMapUrl"
result="map" result="map"
/> />
<feDisplacementMap in="SourceGraphic" in2="map" xChannelSelector="R" yChannelSelector="B" scale="-22" /> <feDisplacementMap in="SourceGraphic" in2="map" xChannelSelector="R" yChannelSelector="B" scale="-22" />
@@ -305,10 +446,50 @@ onBeforeUnmount(() => {
<feImage <feImage
x="0" x="0"
y="0" y="0"
:width="displacementMapSize.width" :width="navbarMapSize.width"
:height="displacementMapSize.height" :height="navbarMapSize.height"
preserveAspectRatio="none" preserveAspectRatio="none"
:href="displacementMapUrl" :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" result="map"
/> />
<feDisplacementMap in="SourceGraphic" in2="map" xChannelSelector="R" yChannelSelector="B" scale="-34" /> <feDisplacementMap in="SourceGraphic" in2="map" xChannelSelector="R" yChannelSelector="B" scale="-34" />
@@ -18,7 +18,9 @@ describe('GlassNavbarRefractionDefs', () => {
let resize: ResizeObserverCallback | undefined let resize: ResizeObserverCallback | undefined
let wrapper: ReturnType<typeof mount> | undefined let wrapper: ReturnType<typeof mount> | undefined
let width: number let width: number
let sidebarWidth: number
let radius: number let radius: number
let sidebar: HTMLElement | undefined
let transparencyReduced: boolean let transparencyReduced: boolean
let transparencyChange: ((event: MediaQueryListEvent) => void) | undefined let transparencyChange: ((event: MediaQueryListEvent) => void) | undefined
let decodePending: Array<{ resolve: () => void; reject: (reason?: unknown) => void }> let decodePending: Array<{ resolve: () => void; reject: (reason?: unknown) => void }>
@@ -30,32 +32,41 @@ describe('GlassNavbarRefractionDefs', () => {
vi.clearAllMocks() vi.clearAllMocks()
effectiveSettings.value = { glassDeformationStrength: 48, glassTranslationStrength: 48 } effectiveSettings.value = { glassDeformationStrength: 48, glassTranslationStrength: 48 }
width = 1423 width = 1423
sidebarWidth = 260
radius = 16 radius = 16
transparencyReduced = false transparencyReduced = false
transparencyChange = undefined transparencyChange = undefined
decodePending = [] decodePending = []
sidebar = undefined
shell = document.createElement('div') shell = document.createElement('div')
shell.className = 'layout-wrapper layout-navbar-floating-eligible layout-navbar-away-from-top' shell.className =
'layout-wrapper layout-horizontal-nav-active layout-navbar-floating-eligible layout-navbar-away-from-top'
shell.dataset.glassNavbarRefraction = 'chromium' shell.dataset.glassNavbarRefraction = 'chromium'
shell.innerHTML = '<header class="layout-navbar"></header>' shell.innerHTML = '<header class="layout-navbar"></header>'
document.body.append(shell) document.body.append(shell)
navbar = shell.querySelector('.layout-navbar') as HTMLElement navbar = shell.querySelector('.layout-navbar') as HTMLElement
Object.assign(document.documentElement.dataset, { theme: 'glass', glassAppearance: 'clear', glassQuality: 'high' }) Object.assign(document.documentElement.dataset, { theme: 'glass', glassAppearance: 'clear', glassQuality: 'high' })
vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockImplementation(() => ({ 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, x: 16,
y: 16, y: 16,
left: 16, left: 16,
top: 16, top: 16,
width, width: elementWidth,
height: 64, height: elementHeight,
right: width + 16, right: elementWidth + 16,
bottom: 80, bottom: elementHeight + 16,
toJSON: () => ({}), toJSON: () => ({}),
})) }
})
vi.spyOn(window, 'getComputedStyle').mockImplementation( vi.spyOn(window, 'getComputedStyle').mockImplementation(
() => element =>
({ ({
borderStartStartRadius: `${radius}px`, borderStartStartRadius: element.classList.contains('layout-vertical-nav') ? '0px' : `${radius}px`,
getPropertyValue: () => '1rem', getPropertyValue: () => '1rem',
}) as unknown as CSSStyleDeclaration, }) as unknown as CSSStyleDeclaration,
) )
@@ -112,6 +123,12 @@ describe('GlassNavbarRefractionDefs', () => {
expect(wrapper?.get('feImage').attributes('width')).toBe(String(expectedWidth)) 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() { function completePendingDecode() {
for (const pending of decodePending.splice(0)) pending.resolve() for (const pending of decodePending.splice(0)) pending.resolve()
} }
@@ -122,6 +139,13 @@ describe('GlassNavbarRefractionDefs', () => {
navbar.dispatchEvent(event) 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('uses computed pixel radius and activates only a decoded map with matching dimensions', async () => { it('uses computed pixel radius and activates only a decoded map with matching dimensions', async () => {
wrapper = mount(GlassNavbarRefractionDefs) wrapper = mount(GlassNavbarRefractionDefs)
expect(shell.dataset.glassNavbarRefractionReady).toBe('false') expect(shell.dataset.glassNavbarRefractionReady).toBe('false')
@@ -132,6 +156,87 @@ describe('GlassNavbarRefractionDefs', () => {
expectReadyForWidth(1423) 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 () => { it('keeps unchanged geometry ready but disables old sampling when the size changes', async () => {
wrapper = mount(GlassNavbarRefractionDefs) wrapper = mount(GlassNavbarRefractionDefs)
await settle() await settle()
@@ -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).toContain('--glass-fixed-shell-backplate-filter: blur(min(var(--glass-blur-raised), 60px))')
expect(styles).toMatch( 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( 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\);/, /\[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) 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', () => { it('limits detached navbar geometry to eligible Transparent and Glass horizontal shells', () => {
const layout = readFileSync(resolve(cwd(), 'src/@layouts/components/VerticalNavLayout.vue'), 'utf8') const layout = readFileSync(resolve(cwd(), 'src/@layouts/components/VerticalNavLayout.vue'), 'utf8')
+128 -2
View File
@@ -54,6 +54,7 @@ html[data-theme='glass'] {
--glass-navbar-backdrop-filter: var(--glass-raised-backdrop-filter); --glass-navbar-backdrop-filter: var(--glass-raised-backdrop-filter);
--glass-navbar-scrolled-backdrop-filter: blur(3px) saturate(115%); --glass-navbar-scrolled-backdrop-filter: blur(3px) saturate(115%);
--glass-navbar-live-filter: none; --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-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-blur: var(--glass-overlay-clarity-blur, 6px);
--glass-overlay-saturate: 115%; --glass-overlay-saturate: 115%;
@@ -228,6 +229,35 @@ html[data-theme='glass'] {
brightness(var(--glass-transmission-brightness)); brightness(var(--glass-transmission-brightness));
--glass-fixed-shell-backplate-filter: blur(min(var(--glass-blur-raised), 60px)) saturate(var(--glass-saturate)) --glass-fixed-shell-backplate-filter: blur(min(var(--glass-blur-raised), 60px)) saturate(var(--glass-saturate))
brightness(var(--glass-transmission-brightness)); 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-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-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); --glass-overlay-blur: min(var(--glass-blur-raised), 36px);
@@ -490,8 +520,8 @@ html[data-theme='glass'] {
position: absolute; position: absolute;
z-index: -1; z-index: -1;
border-inline-end: 1px solid var(--glass-border-raised); border-inline-end: 1px solid var(--glass-border-raised);
-webkit-backdrop-filter: var(--glass-fixed-shell-backdrop-filter); -webkit-backdrop-filter: var(--glass-sidebar-live-filter);
backdrop-filter: var(--glass-fixed-shell-backdrop-filter); backdrop-filter: var(--glass-sidebar-live-filter);
background-color: var(--glass-surface-raised); background-color: var(--glass-surface-raised);
background-image: var(--glass-sheen); background-image: var(--glass-sheen);
box-shadow: var(--glass-shadow-raised); box-shadow: var(--glass-shadow-raised);
@@ -501,6 +531,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。 // Chromium 磨砂 fixed 层直接渲染稳定壁纸背板,固定功能层不得读取随页面重绘的 document backdrop。
&[data-glass-appearance='frosted'][data-glass-quality='css'] body[data-theme='glass'] { &[data-glass-appearance='frosted'][data-glass-quality='css'] body[data-theme='glass'] {
.layout-wrapper.layout-fixed-shell-backplate-active .layout-vertical-nav::before, .layout-wrapper.layout-fixed-shell-backplate-active .layout-vertical-nav::before,
@@ -1764,6 +1847,45 @@ html[data-theme='glass'][data-glass-quality='balanced']:is(
saturate(var(--glass-navbar-saturation)) brightness(var(--glass-navbar-brightness)); 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'] html[data-theme='glass'][data-glass-appearance='tinted']
body[data-theme='glass'] body[data-theme='glass']
.layout-wrapper.layout-navbar-floating-eligible.layout-navbar-away-from-top .layout-wrapper.layout-navbar-floating-eligible.layout-navbar-away-from-top
@@ -1778,6 +1900,10 @@ html[data-theme='glass'][data-glass-appearance='tinted']
} }
@media (prefers-reduced-transparency: reduce) { @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']) html[data-theme='glass']:is([data-glass-appearance='clear'], [data-glass-appearance='tinted'])
body[data-theme='glass'] body[data-theme='glass']
.layout-wrapper.layout-navbar-floating-eligible.layout-navbar-away-from-top .layout-wrapper.layout-navbar-floating-eligible.layout-navbar-away-from-top
@@ -75,6 +75,7 @@ describe('createGlassNavbarDisplacementField', () => {
{ width: 401, height: 72, radius: 16 }, { width: 401, height: 72, radius: 16 },
{ width: 127, height: 64, radius: 8 }, { width: 127, height: 64, radius: 8 },
{ width: 127, height: 64, radius: 32 }, { width: 127, height: 64, radius: 32 },
{ width: 260, height: 800, radius: 0 },
])('keeps two-dimensional sampling forward and inside the image for $width x $height r$radius', geometry => { ])('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 deformation of [0, 48, 100])
for (const translation of [0, 48, 100]) for (const translation of [0, 48, 100])
@@ -115,6 +116,21 @@ describe('createGlassNavbarDisplacementField', () => {
} }
}) })
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', () => { it('clamps invalidly small geometry to a renderable pixel surface', () => {
const field = createGlassNavbarDisplacementField({ height: 0, radius: 20, width: -10 }) const field = createGlassNavbarDisplacementField({ height: 0, radius: 20, width: -10 })
+31 -8
View File
@@ -93,8 +93,10 @@ function smoothstep(value: number) {
/** 外侧快速形成厚度,内侧缓慢回到中性,保留清透中心且不折返背景。 */ /** 外侧快速形成厚度,内侧缓慢回到中性,保留清透中心且不折返背景。 */
function refractionProfile(depth: number, band: number, guard: number) { function refractionProfile(depth: number, band: number, guard: number) {
if (depth <= guard || depth >= band) return 0
const peakDepth = Math.max(guard, band * PEAK_DEPTH_RATIO) const peakDepth = Math.max(guard, band * PEAK_DEPTH_RATIO)
if (depth <= peakDepth) return smoothstep((depth - guard) / (peakDepth - guard)) if (depth <= peakDepth) return smoothstep((depth - guard) / (peakDepth - guard || 1))
return 1 - smoothstep((depth - peakDepth) / (band - peakDepth)) return 1 - smoothstep((depth - peakDepth) / (band - peakDepth))
} }
@@ -112,7 +114,9 @@ export function createGlassNavbarDisplacementField({
const pixelHeight = normalizePixelSize(height) const pixelHeight = normalizePixelSize(height)
const maxRadius = Math.min(pixelWidth, pixelHeight) / 2 const maxRadius = Math.min(pixelWidth, pixelHeight) / 2
const pixelRadius = Number.isFinite(radius) ? Math.max(0, Math.min(maxRadius, radius)) : 0 const pixelRadius = Number.isFinite(radius) ? Math.max(0, Math.min(maxRadius, radius)) : 0
const maximumBand = Math.min(REFRACTION_BAND_PX, pixelRadius * 1.5, Math.min(pixelWidth, pixelHeight) / 2) // 直角固定表面没有圆角半径可供推导,仍使用受最短边约束的直边带;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) const pixels = new Uint8ClampedArray(pixelWidth * pixelHeight * 4)
for (let offset = 0; offset < pixels.length; offset += 4) { for (let offset = 0; offset < pixels.length; offset += 4) {
@@ -133,7 +137,8 @@ export function createGlassNavbarDisplacementField({
const edgeY = Math.min(sampleY, pixelHeight - sampleY) const edgeY = Math.min(sampleY, pixelHeight - sampleY)
const straightWeight = smoothstep(Math.min(1, Math.abs(edgeX - edgeY) / (maximumBand * 2 || 1))) const straightWeight = smoothstep(Math.min(1, Math.abs(edgeX - edgeY) / (maximumBand * 2 || 1)))
const cornerBand = Math.min(maximumBand, pixelRadius) const cornerBand = Math.min(maximumBand, pixelRadius)
const bandWidth = cornerBand + (maximumBand - cornerBand) * straightWeight // 矩形角点没有圆弧法线;固定带宽交给四条直边的轴向剖面处理,避免角点成为采样断点。
const bandWidth = pixelRadius === 0 ? maximumBand : cornerBand + (maximumBand - cornerBand) * straightWeight
const outerGuard = Math.min(OUTER_NEUTRAL_GUARD_PX, bandWidth / 4) const outerGuard = Math.min(OUTER_NEUTRAL_GUARD_PX, bandWidth / 4)
const channelAmplitude = (bandWidth * optics.horizontalRatio * 255) / HIGH_REFRACTION_SCALE_PX const channelAmplitude = (bandWidth * optics.horizontalRatio * 255) / HIGH_REFRACTION_SCALE_PX
@@ -145,6 +150,29 @@ export function createGlassNavbarDisplacementField({
) )
continue 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 profile = distanceInside < bandWidth ? refractionProfile(distanceInside, bandWidth, outerGuard) : 0
const verticalProgress = Math.min(1, (distanceInside - outerGuard) / (bandWidth - outerGuard)) const verticalProgress = Math.min(1, (distanceInside - outerGuard) / (bandWidth - outerGuard))
const verticalProfile = Math.sin(Math.PI * verticalProgress) ** 2 const verticalProfile = Math.sin(Math.PI * verticalProgress) ** 2
@@ -157,11 +185,6 @@ export function createGlassNavbarDisplacementField({
roundedRectangleSignedDistance(sampleX, sampleY - 0.5, pixelWidth, pixelHeight, pixelRadius) roundedRectangleSignedDistance(sampleX, sampleY - 0.5, pixelWidth, pixelHeight, pixelRadius)
const gradientLength = Math.hypot(gradientX, gradientY) || 1 const gradientLength = Math.hypot(gradientX, gradientY) || 1
const offset = (y * pixelWidth + x) * 4 const offset = (y * pixelWidth + x) * 4
// 横向平移从边缘透镜退出后进入,避免两种回落梯度叠加导致局部反向采样。
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
pixels[offset] = clampChannel( pixels[offset] = clampChannel(
DISPLACEMENT_NEUTRAL_CHANNEL + (channelAmplitude * gradientX * profile) / gradientLength + translationChannel, DISPLACEMENT_NEUTRAL_CHANNEL + (channelAmplitude * gradientX * profile) / gradientLength + translationChannel,
) )