mirror of
https://github.com/jxxghp/MoviePilot-Frontend.git
synced 2026-09-10 18:26:45 +08:00
feat(glass): anchor expressive navbar refraction POC
This commit is contained in:
@@ -6,7 +6,20 @@ const DEFAULT_NAVBAR_GEOMETRY = {
|
|||||||
radius: 16,
|
radius: 16,
|
||||||
width: 1200,
|
width: 1200,
|
||||||
}
|
}
|
||||||
const MAP_RESIZE_SETTLE_MS = 180
|
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',
|
||||||
|
'border-radius',
|
||||||
|
'border-start-start-radius',
|
||||||
|
'width',
|
||||||
|
'height',
|
||||||
|
'inline-size',
|
||||||
|
'block-size',
|
||||||
|
]
|
||||||
const displacementMapUrl = ref(NEUTRAL_GLASS_NAVBAR_DISPLACEMENT_MAP)
|
const displacementMapUrl = ref(NEUTRAL_GLASS_NAVBAR_DISPLACEMENT_MAP)
|
||||||
const displacementMapSize = reactive({
|
const displacementMapSize = reactive({
|
||||||
height: DEFAULT_NAVBAR_GEOMETRY.height,
|
height: DEFAULT_NAVBAR_GEOMETRY.height,
|
||||||
@@ -14,46 +27,167 @@ const displacementMapSize = reactive({
|
|||||||
})
|
})
|
||||||
|
|
||||||
let observedNavbar: HTMLElement | null = null
|
let observedNavbar: HTMLElement | null = null
|
||||||
|
let observedShell: HTMLElement | null = null
|
||||||
let resizeObserver: ResizeObserver | null = null
|
let resizeObserver: ResizeObserver | 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 mapRevision = 0
|
||||||
|
let cachedGeometry = ''
|
||||||
|
let cachedMap = NEUTRAL_GLASS_NAVBAR_DISPLACEMENT_MAP
|
||||||
|
let failedGeometry = ''
|
||||||
|
let lastObservedGeometry = ''
|
||||||
|
const geometryTransitions = new Set<string>()
|
||||||
|
|
||||||
function syncDisplacementMap() {
|
/** CSS 档、非水平浮动态与无障碍回退不生成或启用位移图。 */
|
||||||
if (!observedNavbar) return
|
function isRefractionActive() {
|
||||||
|
const { theme, glassAppearance, glassQuality } = document.documentElement.dataset
|
||||||
|
|
||||||
|
return (
|
||||||
|
theme === 'glass' &&
|
||||||
|
(glassAppearance === 'clear' || glassAppearance === 'tinted') &&
|
||||||
|
(glassQuality === 'balanced' || glassQuality === 'high') &&
|
||||||
|
observedShell?.classList.contains('layout-navbar-floating-eligible') &&
|
||||||
|
observedShell.classList.contains('layout-navbar-away-from-top') &&
|
||||||
|
!transparencyQuery?.matches
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 几何或状态变化后立即撤销旧 map,避免在另一个尺寸中采样。 */
|
||||||
|
function invalidateDisplacementMap() {
|
||||||
|
mapRevision += 1
|
||||||
|
observedShell?.setAttribute('data-glass-navbar-refraction-ready', '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()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** map 与 feImage 尺寸同批更新;解码失败或过期结果继续使用 CSS 材质。 */
|
||||||
|
async function syncDisplacementMap() {
|
||||||
|
if (!observedNavbar || !isRefractionActive() || geometryTransitions.size > 0) return
|
||||||
|
const revision = ++mapRevision
|
||||||
|
|
||||||
const bounds = observedNavbar.getBoundingClientRect()
|
const bounds = observedNavbar.getBoundingClientRect()
|
||||||
const styles = getComputedStyle(observedNavbar)
|
const styles = getComputedStyle(observedNavbar)
|
||||||
const floatingRadius = Number.parseFloat(styles.getPropertyValue('--shell-floating-navbar-radius'))
|
// 自定义属性可能保留 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))
|
||||||
|
|
||||||
displacementMapSize.height = height
|
const radius = Number.isFinite(borderRadius) ? borderRadius : DEFAULT_NAVBAR_GEOMETRY.radius
|
||||||
displacementMapSize.width = width
|
const geometryKey = `${width}:${height}:${radius}`
|
||||||
displacementMapUrl.value = createGlassNavbarDisplacementMap({
|
if (lastObservedGeometry !== geometryKey) {
|
||||||
height,
|
lastObservedGeometry = geometryKey
|
||||||
radius: Number.isFinite(floatingRadius)
|
failedGeometry = ''
|
||||||
? floatingRadius
|
|
||||||
: Number.isFinite(borderRadius)
|
|
||||||
? borderRadius
|
|
||||||
: DEFAULT_NAVBAR_GEOMETRY.radius,
|
|
||||||
width,
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 几何动画期间沿用上一张位移图,尺寸稳定后再重建,避免逐帧生成并上传位移纹理。
|
try {
|
||||||
|
if (cachedGeometry !== geometryKey) {
|
||||||
|
if (failedGeometry === geometryKey) return
|
||||||
|
const map = createGlassNavbarDisplacementMap({ height, radius, width })
|
||||||
|
if (map === NEUTRAL_GLASS_NAVBAR_DISPLACEMENT_MAP) {
|
||||||
|
failedGeometry = geometryKey
|
||||||
|
invalidateDisplacementMap()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const decoded = new Image()
|
||||||
|
decoded.src = map
|
||||||
|
await decoded.decode()
|
||||||
|
if (revision !== mapRevision || !isRefractionActive()) return
|
||||||
|
cachedGeometry = geometryKey
|
||||||
|
cachedMap = map
|
||||||
|
failedGeometry = ''
|
||||||
|
}
|
||||||
|
displacementMapSize.height = height
|
||||||
|
displacementMapSize.width = width
|
||||||
|
displacementMapUrl.value = cachedMap
|
||||||
|
await nextTick()
|
||||||
|
if (revision === mapRevision && isRefractionActive()) {
|
||||||
|
observedShell?.setAttribute('data-glass-navbar-refraction-ready', 'true')
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// 位移是增强能力;图片解码失败不阻断导航和原生玻璃表面。
|
||||||
|
if (revision === mapRevision) {
|
||||||
|
failedGeometry = geometryKey
|
||||||
|
invalidateDisplacementMap()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 动画期间使用同族 CSS 材质;尺寸稳定后只重建一次,不逐帧生成或拉伸旧图。
|
||||||
function scheduleDisplacementMapSync() {
|
function scheduleDisplacementMapSync() {
|
||||||
|
invalidateDisplacementMap()
|
||||||
if (resizeTimer !== null) clearTimeout(resizeTimer)
|
if (resizeTimer !== null) clearTimeout(resizeTimer)
|
||||||
|
if (!isRefractionActive()) return
|
||||||
resizeTimer = setTimeout(() => {
|
resizeTimer = setTimeout(() => {
|
||||||
resizeTimer = null
|
resizeTimer = null
|
||||||
syncDisplacementMap()
|
void syncDisplacementMap()
|
||||||
}, MAP_RESIZE_SETTLE_MS)
|
}, MAP_RESIZE_SETTLE_MS)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function handleGeometryTransition(event: TransitionEvent) {
|
||||||
|
if (
|
||||||
|
event.target !== observedNavbar ||
|
||||||
|
!/^(inset|top|left|right|width|height|inline-size|block-size|border.*radius)/u.test(event.propertyName)
|
||||||
|
)
|
||||||
|
return
|
||||||
|
if (event.type === 'transitionrun') {
|
||||||
|
geometryTransitions.add(event.propertyName)
|
||||||
|
invalidateDisplacementMap()
|
||||||
|
} else {
|
||||||
|
geometryTransitions.delete(event.propertyName)
|
||||||
|
if (geometryTransitions.size === 0) scheduleDisplacementMapSync()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
observedNavbar = document.querySelector('.layout-wrapper[data-glass-navbar-refraction="chromium"] .layout-navbar')
|
observedNavbar = document.querySelector('.layout-wrapper[data-glass-navbar-refraction="chromium"] .layout-navbar')
|
||||||
if (!observedNavbar) return
|
if (!observedNavbar) return
|
||||||
|
observedShell = observedNavbar.closest('.layout-wrapper')
|
||||||
|
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'],
|
||||||
|
})
|
||||||
|
if (observedShell) {
|
||||||
|
stateObserver.observe(observedShell, {
|
||||||
|
attributes: true,
|
||||||
|
attributeOldValue: true,
|
||||||
|
attributeFilter: ['class', 'style'],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
stateObserver.observe(observedNavbar, {
|
||||||
|
attributes: true,
|
||||||
|
attributeOldValue: true,
|
||||||
|
attributeFilter: ['class', 'style'],
|
||||||
|
})
|
||||||
|
observedNavbar.addEventListener('transitionrun', handleGeometryTransition)
|
||||||
|
observedNavbar.addEventListener('transitionend', handleGeometryTransition)
|
||||||
|
observedNavbar.addEventListener('transitioncancel', handleGeometryTransition)
|
||||||
|
|
||||||
syncDisplacementMap()
|
scheduleDisplacementMapSync()
|
||||||
if (typeof ResizeObserver === 'undefined') {
|
if (typeof ResizeObserver === 'undefined') {
|
||||||
window.addEventListener('resize', scheduleDisplacementMapSync, { passive: true })
|
window.addEventListener('resize', scheduleDisplacementMapSync, { passive: true })
|
||||||
|
|
||||||
@@ -65,12 +199,23 @@ onMounted(() => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
onBeforeUnmount(() => {
|
onBeforeUnmount(() => {
|
||||||
|
invalidateDisplacementMap()
|
||||||
|
geometryTransitions.clear()
|
||||||
if (resizeTimer !== null) clearTimeout(resizeTimer)
|
if (resizeTimer !== null) clearTimeout(resizeTimer)
|
||||||
resizeTimer = null
|
resizeTimer = null
|
||||||
resizeObserver?.disconnect()
|
resizeObserver?.disconnect()
|
||||||
resizeObserver = null
|
resizeObserver = null
|
||||||
|
stateObserver?.disconnect()
|
||||||
|
stateObserver = null
|
||||||
|
transparencyQuery?.removeEventListener('change', scheduleDisplacementMapSync)
|
||||||
|
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
|
observedNavbar = null
|
||||||
|
observedShell = null
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -79,10 +224,10 @@ onBeforeUnmount(() => {
|
|||||||
<defs>
|
<defs>
|
||||||
<filter
|
<filter
|
||||||
id="glass-navbar-live-refraction-balanced"
|
id="glass-navbar-live-refraction-balanced"
|
||||||
x="-8%"
|
x="0%"
|
||||||
y="-80%"
|
y="0%"
|
||||||
width="116%"
|
width="100%"
|
||||||
height="260%"
|
height="100%"
|
||||||
color-interpolation-filters="sRGB"
|
color-interpolation-filters="sRGB"
|
||||||
>
|
>
|
||||||
<feImage
|
<feImage
|
||||||
@@ -99,10 +244,10 @@ onBeforeUnmount(() => {
|
|||||||
|
|
||||||
<filter
|
<filter
|
||||||
id="glass-navbar-live-refraction-high"
|
id="glass-navbar-live-refraction-high"
|
||||||
x="-12%"
|
x="0%"
|
||||||
y="-100%"
|
y="0%"
|
||||||
width="124%"
|
width="100%"
|
||||||
height="300%"
|
height="100%"
|
||||||
color-interpolation-filters="sRGB"
|
color-interpolation-filters="sRGB"
|
||||||
>
|
>
|
||||||
<feImage
|
<feImage
|
||||||
|
|||||||
@@ -0,0 +1,272 @@
|
|||||||
|
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'
|
||||||
|
|
||||||
|
vi.mock('@/utils/glassNavbarRefraction', () => ({
|
||||||
|
createGlassNavbarDisplacementMap: vi.fn(() => 'data:image/png;base64,test'),
|
||||||
|
NEUTRAL_GLASS_NAVBAR_DISPLACEMENT_MAP: 'neutral',
|
||||||
|
}))
|
||||||
|
|
||||||
|
describe('GlassNavbarRefractionDefs', () => {
|
||||||
|
let shell: HTMLDivElement
|
||||||
|
let navbar: HTMLElement
|
||||||
|
let resize: ResizeObserverCallback | undefined
|
||||||
|
let wrapper: ReturnType<typeof mount> | undefined
|
||||||
|
let width: number
|
||||||
|
let radius: number
|
||||||
|
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()
|
||||||
|
width = 1423
|
||||||
|
radius = 16
|
||||||
|
transparencyReduced = false
|
||||||
|
transparencyChange = undefined
|
||||||
|
decodePending = []
|
||||||
|
shell = document.createElement('div')
|
||||||
|
shell.className = 'layout-wrapper 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(() => ({
|
||||||
|
x: 16,
|
||||||
|
y: 16,
|
||||||
|
left: 16,
|
||||||
|
top: 16,
|
||||||
|
width,
|
||||||
|
height: 64,
|
||||||
|
right: width + 16,
|
||||||
|
bottom: 80,
|
||||||
|
toJSON: () => ({}),
|
||||||
|
}))
|
||||||
|
vi.spyOn(window, 'getComputedStyle').mockImplementation(
|
||||||
|
() =>
|
||||||
|
({
|
||||||
|
borderStartStartRadius: `${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 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)
|
||||||
|
}
|
||||||
|
|
||||||
|
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({ width: 1423, height: 64, radius: 16 })
|
||||||
|
expectReadyForWidth(1423)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('disables old sampling during resize and caches unchanged geometry', async () => {
|
||||||
|
wrapper = mount(GlassNavbarRefractionDefs)
|
||||||
|
await settle()
|
||||||
|
resize?.([], {} as ResizeObserver)
|
||||||
|
expect(shell.dataset.glassNavbarRefractionReady).toBe('false')
|
||||||
|
await settle()
|
||||||
|
expect(createGlassNavbarDisplacementMap).toHaveBeenCalledTimes(1)
|
||||||
|
width = 1200
|
||||||
|
resize?.([], {} as ResizeObserver)
|
||||||
|
await settle()
|
||||||
|
expect(createGlassNavbarDisplacementMap).toHaveBeenLastCalledWith({ width: 1200, height: 64, radius: 16 })
|
||||||
|
expectReadyForWidth(1200)
|
||||||
|
})
|
||||||
|
|
||||||
|
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('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({ width: 1200, height: 64, radius: 16 })
|
||||||
|
expectReadyForWidth(1200)
|
||||||
|
|
||||||
|
radius = 20
|
||||||
|
dispatchTransition('transitionrun', 'border-radius')
|
||||||
|
dispatchTransition('transitioncancel', 'border-radius')
|
||||||
|
await settle()
|
||||||
|
expect(createGlassNavbarDisplacementMap).toHaveBeenLastCalledWith({ 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({ 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({ 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()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -393,10 +393,17 @@ describe('glass overlay material styles', () => {
|
|||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('scopes live navbar refraction to Chromium clear and tinted floating shells', () => {
|
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 styles = readFileSync(resolve(cwd(), 'src/styles/themes/glass.scss'), 'utf8')
|
||||||
const filterDefinitions = readFileSync(resolve(cwd(), 'src/components/theme/GlassNavbarRefractionDefs.vue'), 'utf8')
|
const filterDefinitions = readFileSync(resolve(cwd(), 'src/components/theme/GlassNavbarRefractionDefs.vue'), 'utf8')
|
||||||
const refractionUtilities = readFileSync(resolve(cwd(), 'src/utils/glassNavbarRefraction.ts'), 'utf8')
|
const refractionUtilities = readFileSync(resolve(cwd(), 'src/utils/glassNavbarRefraction.ts'), '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 baseMaterialRule = styles.slice(baseMaterialStart, svgEnhancementStart)
|
||||||
|
const reducedTransparencyRule = styles.slice(reducedTransparencyStart, reducedMotionStart)
|
||||||
|
const reducedMotionRule = styles.slice(reducedMotionStart)
|
||||||
|
|
||||||
expect(styles).toContain("data-glass-navbar-refraction='chromium'")
|
expect(styles).toContain("data-glass-navbar-refraction='chromium'")
|
||||||
expect(styles).toContain("url('#glass-navbar-live-refraction-balanced')")
|
expect(styles).toContain("url('#glass-navbar-live-refraction-balanced')")
|
||||||
@@ -419,27 +426,46 @@ describe('glass overlay material styles', () => {
|
|||||||
expect(styles).toContain(
|
expect(styles).toContain(
|
||||||
'inset-inline-start 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)',
|
||||||
)
|
)
|
||||||
const highQualityStart = styles.indexOf("html[data-theme='glass'][data-glass-quality='high']")
|
expect(baseMaterialStart).toBeGreaterThanOrEqual(0)
|
||||||
const liveRefractionStart = styles.lastIndexOf(
|
expect(svgEnhancementStart).toBeGreaterThan(baseMaterialStart)
|
||||||
"html[data-theme='glass']:is([data-glass-appearance='clear'], [data-glass-appearance='tinted'])",
|
expect(baseMaterialRule).toContain('.layout-wrapper.layout-navbar-floating-eligible.layout-navbar-away-from-top')
|
||||||
highQualityStart,
|
expect(baseMaterialRule).not.toContain("data-glass-navbar-refraction='chromium'")
|
||||||
|
expect(styles).toContain('--glass-navbar-live-filter: none;')
|
||||||
|
expect(baseMaterialRule).toContain('--glass-navbar-live-filter: blur(1.5px) saturate(118%)')
|
||||||
|
expect(baseMaterialRule).toContain('-webkit-backdrop-filter: var(--glass-navbar-live-filter) !important')
|
||||||
|
expect(baseMaterialRule).toContain('backdrop-filter: var(--glass-navbar-live-filter) !important')
|
||||||
|
expect(baseMaterialRule).toContain('border: 0 !important')
|
||||||
|
expect(baseMaterialRule).toContain('linear-gradient(145deg, rgba(255, 255, 255, 0.18), transparent 38%)')
|
||||||
|
expect(baseMaterialRule).toContain('inset 0 1px 2px rgba(255, 255, 255, 0.4)')
|
||||||
|
expect(baseMaterialRule).not.toContain('inset 0 1px 0 rgba(255, 255, 255, 0.4)')
|
||||||
|
expect(baseMaterialRule).not.toContain("url('#glass-navbar-live-refraction-")
|
||||||
|
expect(styles).toMatch(
|
||||||
|
/html\[data-theme='glass'\]\[data-glass-quality='balanced'\]:is\([\s\S]*?\.layout-wrapper\[data-glass-navbar-refraction='chromium'\]\[data-glass-navbar-refraction-ready='true'\]\.layout-navbar-floating-eligible\.layout-navbar-away-from-top\s+\.layout-navbar\s*\{[\s\S]*?--glass-navbar-live-filter: url\('#glass-navbar-live-refraction-balanced'\) blur\(1\.5px\) saturate\(118%\);/,
|
||||||
|
)
|
||||||
|
expect(styles).toMatch(
|
||||||
|
/html\[data-theme='glass'\]\[data-glass-quality='high'\]:is\([\s\S]*?\.layout-wrapper\[data-glass-navbar-refraction='chromium'\]\[data-glass-navbar-refraction-ready='true'\]\.layout-navbar-floating-eligible\.layout-navbar-away-from-top\s+\.layout-navbar\s*\{[\s\S]*?--glass-navbar-live-filter: url\('#glass-navbar-live-refraction-high'\) blur\(1px\) saturate\(122%\);/,
|
||||||
|
)
|
||||||
|
expect(styles).not.toMatch(
|
||||||
|
/data-glass-navbar-refraction='chromium'\](?!\[data-glass-navbar-refraction-ready='true'\])[^{]*\{[\s\S]*?url\('#glass-navbar-live-refraction-/u,
|
||||||
)
|
)
|
||||||
const liveRefractionRule = styles.slice(liveRefractionStart, highQualityStart)
|
|
||||||
|
|
||||||
expect(liveRefractionStart).toBeGreaterThanOrEqual(0)
|
|
||||||
expect(highQualityStart).toBeGreaterThan(liveRefractionStart)
|
|
||||||
expect(liveRefractionRule).toContain("backdrop-filter: url('#glass-navbar-live-refraction-balanced')")
|
|
||||||
expect(liveRefractionRule).toContain('border: 0 !important')
|
|
||||||
expect(liveRefractionRule).toContain('linear-gradient(145deg, rgba(255, 255, 255, 0.13), transparent 34%)')
|
|
||||||
expect(liveRefractionRule).not.toContain('inset 0 1px 0 rgba(255, 255, 255, 0.4)')
|
|
||||||
expect(styles).toContain('inline-size: min(100vw, variables.$layout-boxed-content-width)')
|
expect(styles).toContain('inline-size: min(100vw, variables.$layout-boxed-content-width)')
|
||||||
expect(styles).toContain('transform: translateX(-50%) !important')
|
expect(styles).toContain('transform: translateX(-50%) !important')
|
||||||
expect(styles).toMatch(
|
expect(styles).toMatch(
|
||||||
/\[data-glass-appearance='tinted'\][\s\S]*?\.layout-navbar\s*\{[\s\S]*?--glass-material-accent-rgb/,
|
/\[data-glass-appearance='tinted'\][\s\S]*?\.layout-navbar\s*\{[\s\S]*?--glass-material-accent-rgb/,
|
||||||
)
|
)
|
||||||
expect(liveRefractionRule).not.toContain('&::before')
|
expect(baseMaterialRule).not.toContain('&::before')
|
||||||
expect(styles).not.toContain('&::after')
|
expect(styles).not.toContain('&::after')
|
||||||
expect(styles).not.toContain("[data-glass-appearance='frosted'][data-glass-navbar-refraction='chromium']")
|
expect(styles).not.toContain("[data-glass-appearance='frosted'][data-glass-navbar-refraction='chromium']")
|
||||||
|
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')
|
||||||
expect(filterDefinitions).toContain('createGlassNavbarDisplacementMap')
|
expect(filterDefinitions).toContain('createGlassNavbarDisplacementMap')
|
||||||
expect(filterDefinitions).toContain('NEUTRAL_GLASS_NAVBAR_DISPLACEMENT_MAP')
|
expect(filterDefinitions).toContain('NEUTRAL_GLASS_NAVBAR_DISPLACEMENT_MAP')
|
||||||
expect(filterDefinitions).toContain('getBoundingClientRect()')
|
expect(filterDefinitions).toContain('getBoundingClientRect()')
|
||||||
@@ -447,20 +473,24 @@ describe('glass overlay material styles', () => {
|
|||||||
expect(filterDefinitions).toContain('in2="map"')
|
expect(filterDefinitions).toContain('in2="map"')
|
||||||
expect(filterDefinitions).toContain(':width="displacementMapSize.width"')
|
expect(filterDefinitions).toContain(':width="displacementMapSize.width"')
|
||||||
expect(filterDefinitions).toContain(':height="displacementMapSize.height"')
|
expect(filterDefinitions).toContain(':height="displacementMapSize.height"')
|
||||||
expect(filterDefinitions).not.toContain('width="100%"')
|
expect(filterDefinitions).not.toMatch(/<feImage\b[^>]*\bwidth="100%"/u)
|
||||||
expect(filterDefinitions).not.toContain('height="100%"')
|
expect(filterDefinitions).not.toMatch(/<feImage\b[^>]*\bheight="100%"/u)
|
||||||
expect(filterDefinitions).toContain('scale="-22"')
|
expect(filterDefinitions).toContain('scale="-22"')
|
||||||
expect(filterDefinitions).toContain('scale="-34"')
|
expect(filterDefinitions).toContain('scale="-34"')
|
||||||
expect(filterDefinitions).toContain('x="-8%"')
|
expect(filterDefinitions).toContain('x="0%"')
|
||||||
expect(filterDefinitions).toContain('width="116%"')
|
expect(filterDefinitions).toContain('y="0%"')
|
||||||
expect(filterDefinitions).toContain('x="-12%"')
|
expect(filterDefinitions).toContain('width="100%"')
|
||||||
expect(filterDefinitions).toContain('width="124%"')
|
expect(filterDefinitions).toContain('height="100%"')
|
||||||
|
expect(filterDefinitions).not.toContain('x="-8%"')
|
||||||
|
expect(filterDefinitions).not.toContain('width="116%"')
|
||||||
|
expect(filterDefinitions).not.toContain('x="-12%"')
|
||||||
|
expect(filterDefinitions).not.toContain('width="124%"')
|
||||||
expect(filterDefinitions).not.toContain('horizontal-continuity')
|
expect(filterDefinitions).not.toContain('horizontal-continuity')
|
||||||
expect(filterDefinitions).not.toContain('<feComposite')
|
expect(filterDefinitions).not.toContain('<feComposite')
|
||||||
expect(filterDefinitions).not.toContain('<feGaussianBlur')
|
expect(filterDefinitions).not.toContain('<feGaussianBlur')
|
||||||
expect(refractionUtilities).toContain('createGlassNavbarDisplacementField')
|
expect(refractionUtilities).toContain('createGlassNavbarDisplacementField')
|
||||||
expect(refractionUtilities).toContain('OUTER_NEUTRAL_GUARD_PX')
|
expect(refractionUtilities).toContain('OUTER_NEUTRAL_GUARD_PX')
|
||||||
expect(refractionUtilities).toContain('Math.sin(Math.PI * normalizedDistance)')
|
expect(refractionUtilities).toContain('refractionProfile(distanceInside, bandWidth, outerGuard)')
|
||||||
expect(refractionUtilities).toContain("canvas.toDataURL('image/png')")
|
expect(refractionUtilities).toContain("canvas.toDataURL('image/png')")
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -53,6 +53,7 @@ html[data-theme='glass'] {
|
|||||||
--glass-control-prominent-backdrop-filter: none;
|
--glass-control-prominent-backdrop-filter: none;
|
||||||
--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-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%;
|
||||||
@@ -1652,10 +1653,10 @@ html[data-theme='glass'] body[data-theme='glass'] .native-login-field:focus-with
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 实时折射表面在贴顶与浮动状态使用同一布局坐标系,只插值真实 inset,避免切换 transform 采样空间。
|
// 浮动顶栏使用同一布局坐标系,只插值真实 inset,避免切换 transform 采样空间。
|
||||||
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[data-glass-navbar-refraction='chromium'].layout-navbar-floating-eligible
|
.layout-wrapper.layout-navbar-floating-eligible
|
||||||
.layout-navbar {
|
.layout-navbar {
|
||||||
inline-size: auto !important;
|
inline-size: auto !important;
|
||||||
inset-block-start: 0 !important;
|
inset-block-start: 0 !important;
|
||||||
@@ -1680,42 +1681,82 @@ html[data-theme='glass']:is([data-glass-appearance='clear'], [data-glass-appeara
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 位移、材质和轮廓由同一个真实尺寸的表面完成;顶部不叠加硬高光线。
|
// 基础材质由单一真实表面承载;柔和迎光表达厚度,不叠加父子两层硬高光。
|
||||||
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[data-glass-navbar-refraction='chromium'].layout-navbar-floating-eligible.layout-navbar-away-from-top
|
.layout-wrapper.layout-navbar-floating-eligible.layout-navbar-away-from-top
|
||||||
.layout-navbar {
|
.layout-navbar {
|
||||||
|
--glass-navbar-live-filter: blur(1.5px) saturate(118%);
|
||||||
|
|
||||||
border: 0 !important;
|
border: 0 !important;
|
||||||
-webkit-backdrop-filter: blur(1.5px) saturate(118%) !important;
|
-webkit-backdrop-filter: var(--glass-navbar-live-filter) !important;
|
||||||
backdrop-filter: url('#glass-navbar-live-refraction-balanced') blur(1.5px) saturate(118%) !important;
|
backdrop-filter: var(--glass-navbar-live-filter) !important;
|
||||||
background:
|
background:
|
||||||
linear-gradient(145deg, rgba(255, 255, 255, 0.13), transparent 34%),
|
linear-gradient(145deg, rgba(255, 255, 255, 0.18), transparent 38%),
|
||||||
linear-gradient(rgba(7, 14, 25, 0.06), rgba(7, 14, 25, 0.13)) !important;
|
linear-gradient(rgba(7, 14, 25, 0.06), rgba(7, 14, 25, 0.13)) !important;
|
||||||
box-shadow:
|
box-shadow:
|
||||||
inset 1px 0 0 rgba(255, 255, 255, 0.2),
|
inset 0 0 0 1px rgba(255, 255, 255, 0.24),
|
||||||
inset -1px 0 0 rgba(255, 255, 255, 0.16),
|
inset 0 1px 2px rgba(255, 255, 255, 0.4),
|
||||||
inset 0 -1px 0 rgba(4, 10, 20, 0.22),
|
inset 0 -1px 2px rgba(4, 10, 20, 0.22),
|
||||||
0 12px 32px rgba(3, 7, 18, 0.16) !important;
|
0 12px 32px rgba(3, 7, 18, 0.16) !important;
|
||||||
inset-block-start: var(--shell-floating-navbar-inset) !important;
|
inset-block-start: var(--shell-floating-navbar-inset) !important;
|
||||||
inset-inline: 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(
|
html[data-theme='glass'][data-glass-quality='high']:is(
|
||||||
[data-glass-appearance='clear'],
|
[data-glass-appearance='clear'],
|
||||||
[data-glass-appearance='tinted']
|
[data-glass-appearance='tinted']
|
||||||
)
|
)
|
||||||
body[data-theme='glass']
|
body[data-theme='glass']
|
||||||
.layout-wrapper[data-glass-navbar-refraction='chromium'].layout-navbar-floating-eligible.layout-navbar-away-from-top
|
.layout-wrapper[data-glass-navbar-refraction='chromium'][data-glass-navbar-refraction-ready='true'].layout-navbar-floating-eligible.layout-navbar-away-from-top
|
||||||
.layout-navbar {
|
.layout-navbar {
|
||||||
backdrop-filter: url('#glass-navbar-live-refraction-high') blur(1px) saturate(122%) !important;
|
--glass-navbar-live-filter: url('#glass-navbar-live-refraction-high') blur(1px) saturate(122%);
|
||||||
|
}
|
||||||
|
|
||||||
|
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(1.5px) saturate(118%);
|
||||||
}
|
}
|
||||||
|
|
||||||
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[data-glass-navbar-refraction='chromium'].layout-navbar-floating-eligible.layout-navbar-away-from-top
|
.layout-wrapper.layout-navbar-floating-eligible.layout-navbar-away-from-top
|
||||||
.layout-navbar {
|
.layout-navbar {
|
||||||
background:
|
background:
|
||||||
linear-gradient(145deg, rgba(255, 255, 255, 0.14), transparent 36%),
|
linear-gradient(145deg, rgba(255, 255, 255, 0.18), transparent 38%),
|
||||||
linear-gradient(rgba(var(--glass-material-accent-rgb), 0.09), rgba(var(--glass-material-accent-rgb), 0.035)),
|
linear-gradient(rgba(var(--glass-material-accent-rgb), 0.09), rgba(var(--glass-material-accent-rgb), 0.035)),
|
||||||
linear-gradient(rgba(7, 14, 25, 0.08), rgba(7, 14, 25, 0.15)) !important;
|
linear-gradient(rgba(7, 14, 25, 0.08), rgba(7, 14, 25, 0.15)) !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-transparency: 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-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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -15,10 +15,50 @@ describe('createGlassNavbarDisplacementField', () => {
|
|||||||
expect(field.height).toBe(41)
|
expect(field.height).toBe(41)
|
||||||
expect(pixelAt(field, 50, 0)).toEqual([128, 128, 128, 255])
|
expect(pixelAt(field, 50, 0)).toEqual([128, 128, 128, 255])
|
||||||
expect(pixelAt(field, 50, 20)).toEqual([128, 128, 128, 255])
|
expect(pixelAt(field, 50, 20)).toEqual([128, 128, 128, 255])
|
||||||
expect(pixelAt(field, 50, 5)[2]).toBeLessThan(64)
|
expect(pixelAt(field, 50, 5)[2]).toBeLessThan(120)
|
||||||
expect(pixelAt(field, 5, 20)[0]).toBeLessThan(64)
|
expect(pixelAt(field, 5, 20)[0]).toBeLessThan(120)
|
||||||
expect(pixelAt(field, 95, 20)[0]).toBeGreaterThan(192)
|
expect(pixelAt(field, 95, 20)[0]).toBeGreaterThan(136)
|
||||||
expect(pixelAt(field, 50, 35)[2]).toBeGreaterThan(192)
|
expect(pixelAt(field, 50, 35)[2]).toBeGreaterThan(136)
|
||||||
|
})
|
||||||
|
|
||||||
|
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 },
|
||||||
|
])('keeps two-dimensional sampling forward and inside the image for $width x $height r$radius', geometry => {
|
||||||
|
const field = createGlassNavbarDisplacementField(geometry)
|
||||||
|
for (const scale of [-22, -34]) {
|
||||||
|
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('clamps invalidly small geometry to a renderable pixel surface', () => {
|
it('clamps invalidly small geometry to a renderable pixel surface', () => {
|
||||||
|
|||||||
@@ -31,10 +31,12 @@ 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'
|
'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 DISPLACEMENT_NEUTRAL_CHANNEL = 128
|
||||||
const DISPLACEMENT_CHANNEL_AMPLITUDE = 127
|
const HIGH_REFRACTION_SCALE_PX = 34
|
||||||
const OUTER_NEUTRAL_GUARD_PX = 2
|
const OUTER_NEUTRAL_GUARD_PX = 0.5
|
||||||
const REFRACTION_BAND_PX = 12
|
const REFRACTION_BAND_PX = 24
|
||||||
const REFRACTION_PROFILE_POWER = 2
|
// 峰值靠近外沿,内侧有足够距离释放放大率;对称波峰会在窄轮廓内反向采样。
|
||||||
|
const MAX_DISPLACEMENT_BAND_RATIO = 0.42
|
||||||
|
const PEAK_DEPTH_RATIO = 0.16
|
||||||
|
|
||||||
function normalizePixelSize(value: number) {
|
function normalizePixelSize(value: number) {
|
||||||
return Number.isFinite(value) ? Math.max(1, Math.round(value)) : 1
|
return Number.isFinite(value) ? Math.max(1, Math.round(value)) : 1
|
||||||
@@ -53,6 +55,17 @@ function clampChannel(value: number) {
|
|||||||
return Math.max(0, Math.min(255, Math.round(value)))
|
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) {
|
||||||
|
const peakDepth = Math.max(guard, band * PEAK_DEPTH_RATIO)
|
||||||
|
if (depth <= peakDepth) return smoothstep((depth - guard) / (peakDepth - guard))
|
||||||
|
return 1 - smoothstep((depth - peakDepth) / (band - peakDepth))
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 生成圆角表面的法线位移场。
|
* 生成圆角表面的法线位移场。
|
||||||
* 外轮廓和内区都保持中性采样,避免折射在裁剪边界或主体内容区形成整带错位。
|
* 外轮廓和内区都保持中性采样,避免折射在裁剪边界或主体内容区形成整带错位。
|
||||||
@@ -66,8 +79,7 @@ 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 bandWidth = Math.min(REFRACTION_BAND_PX, Math.min(pixelWidth, pixelHeight) / 2)
|
const maximumBand = Math.min(REFRACTION_BAND_PX, pixelRadius * 1.5, Math.min(pixelWidth, pixelHeight) / 2)
|
||||||
const outerGuard = Math.min(OUTER_NEUTRAL_GUARD_PX, bandWidth / 4)
|
|
||||||
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) {
|
||||||
@@ -83,11 +95,18 @@ export function createGlassNavbarDisplacementField({
|
|||||||
const sampleY = y + 0.5
|
const sampleY = y + 0.5
|
||||||
const signedDistance = roundedRectangleSignedDistance(sampleX, sampleY, pixelWidth, pixelHeight, pixelRadius)
|
const signedDistance = roundedRectangleSignedDistance(sampleX, sampleY, pixelWidth, pixelHeight, pixelRadius)
|
||||||
const distanceInside = -signedDistance
|
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 = cornerBand + (maximumBand - cornerBand) * straightWeight
|
||||||
|
const outerGuard = Math.min(OUTER_NEUTRAL_GUARD_PX, bandWidth / 4)
|
||||||
|
const channelAmplitude = (bandWidth * MAX_DISPLACEMENT_BAND_RATIO * 255) / HIGH_REFRACTION_SCALE_PX
|
||||||
|
|
||||||
if (signedDistance > 0 || distanceInside <= outerGuard || distanceInside >= bandWidth) continue
|
if (signedDistance > 0 || distanceInside <= outerGuard || distanceInside >= bandWidth) continue
|
||||||
|
|
||||||
const normalizedDistance = (distanceInside - outerGuard) / (bandWidth - outerGuard)
|
const profile = refractionProfile(distanceInside, bandWidth, outerGuard)
|
||||||
const profile = Math.sin(Math.PI * normalizedDistance) ** REFRACTION_PROFILE_POWER
|
|
||||||
const gradientX =
|
const gradientX =
|
||||||
roundedRectangleSignedDistance(sampleX + 0.5, sampleY, pixelWidth, pixelHeight, pixelRadius) -
|
roundedRectangleSignedDistance(sampleX + 0.5, sampleY, pixelWidth, pixelHeight, pixelRadius) -
|
||||||
roundedRectangleSignedDistance(sampleX - 0.5, sampleY, pixelWidth, pixelHeight, pixelRadius)
|
roundedRectangleSignedDistance(sampleX - 0.5, sampleY, pixelWidth, pixelHeight, pixelRadius)
|
||||||
@@ -98,10 +117,10 @@ export function createGlassNavbarDisplacementField({
|
|||||||
const offset = (y * pixelWidth + x) * 4
|
const offset = (y * pixelWidth + x) * 4
|
||||||
|
|
||||||
pixels[offset] = clampChannel(
|
pixels[offset] = clampChannel(
|
||||||
DISPLACEMENT_NEUTRAL_CHANNEL + (DISPLACEMENT_CHANNEL_AMPLITUDE * gradientX * profile) / gradientLength,
|
DISPLACEMENT_NEUTRAL_CHANNEL + (channelAmplitude * gradientX * profile) / gradientLength,
|
||||||
)
|
)
|
||||||
pixels[offset + 2] = clampChannel(
|
pixels[offset + 2] = clampChannel(
|
||||||
DISPLACEMENT_NEUTRAL_CHANNEL + (DISPLACEMENT_CHANNEL_AMPLITUDE * gradientY * profile) / gradientLength,
|
DISPLACEMENT_NEUTRAL_CHANNEL + (channelAmplitude * gradientY * profile) / gradientLength,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user