diff --git a/src/components/theme/GlassNavbarRefractionDefs.vue b/src/components/theme/GlassNavbarRefractionDefs.vue index 2c311d25..8f810daa 100644 --- a/src/components/theme/GlassNavbarRefractionDefs.vue +++ b/src/components/theme/GlassNavbarRefractionDefs.vue @@ -6,7 +6,20 @@ const DEFAULT_NAVBAR_GEOMETRY = { radius: 16, 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 displacementMapSize = reactive({ height: DEFAULT_NAVBAR_GEOMETRY.height, @@ -14,46 +27,167 @@ const displacementMapSize = reactive({ }) let observedNavbar: HTMLElement | null = null +let observedShell: HTMLElement | null = null let resizeObserver: ResizeObserver | null = null +let stateObserver: MutationObserver | null = null let resizeTimer: ReturnType | 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() -function syncDisplacementMap() { - if (!observedNavbar) return +/** CSS 档、非水平浮动态与无障碍回退不生成或启用位移图。 */ +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 styles = getComputedStyle(observedNavbar) - const floatingRadius = Number.parseFloat(styles.getPropertyValue('--shell-floating-navbar-radius')) + // 自定义属性可能保留 rem;只有计算后的圆角与位移图使用同一 CSS 像素坐标。 const borderRadius = Number.parseFloat(styles.borderStartStartRadius) const height = Math.max(1, Math.round(bounds.height)) const width = Math.max(1, Math.round(bounds.width)) - displacementMapSize.height = height - displacementMapSize.width = width - displacementMapUrl.value = createGlassNavbarDisplacementMap({ - height, - radius: Number.isFinite(floatingRadius) - ? floatingRadius - : Number.isFinite(borderRadius) - ? borderRadius - : DEFAULT_NAVBAR_GEOMETRY.radius, - width, - }) + const radius = Number.isFinite(borderRadius) ? borderRadius : DEFAULT_NAVBAR_GEOMETRY.radius + const geometryKey = `${width}:${height}:${radius}` + if (lastObservedGeometry !== geometryKey) { + lastObservedGeometry = geometryKey + failedGeometry = '' + } + + 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() { + invalidateDisplacementMap() if (resizeTimer !== null) clearTimeout(resizeTimer) + if (!isRefractionActive()) return resizeTimer = setTimeout(() => { resizeTimer = null - syncDisplacementMap() + void syncDisplacementMap() }, 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(() => { observedNavbar = document.querySelector('.layout-wrapper[data-glass-navbar-refraction="chromium"] .layout-navbar') 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') { window.addEventListener('resize', scheduleDisplacementMapSync, { passive: true }) @@ -65,12 +199,23 @@ onMounted(() => { }) onBeforeUnmount(() => { + invalidateDisplacementMap() + geometryTransitions.clear() if (resizeTimer !== null) clearTimeout(resizeTimer) resizeTimer = null resizeObserver?.disconnect() 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) observedNavbar = null + observedShell = null }) @@ -79,10 +224,10 @@ onBeforeUnmount(() => { { ({ + 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 | 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 = '
' + 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((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() + }) +}) diff --git a/src/styles/__tests__/glassOverlayMaterial.spec.ts b/src/styles/__tests__/glassOverlayMaterial.spec.ts index 6ec49725..c2dab47a 100644 --- a/src/styles/__tests__/glassOverlayMaterial.spec.ts +++ b/src/styles/__tests__/glassOverlayMaterial.spec.ts @@ -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 filterDefinitions = readFileSync(resolve(cwd(), 'src/components/theme/GlassNavbarRefractionDefs.vue'), '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("url('#glass-navbar-live-refraction-balanced')") @@ -419,27 +426,46 @@ describe('glass overlay material styles', () => { expect(styles).toContain( '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']") - const liveRefractionStart = styles.lastIndexOf( - "html[data-theme='glass']:is([data-glass-appearance='clear'], [data-glass-appearance='tinted'])", - highQualityStart, + expect(baseMaterialStart).toBeGreaterThanOrEqual(0) + expect(svgEnhancementStart).toBeGreaterThan(baseMaterialStart) + expect(baseMaterialRule).toContain('.layout-wrapper.layout-navbar-floating-eligible.layout-navbar-away-from-top') + 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('transform: translateX(-50%) !important') expect(styles).toMatch( /\[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("[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('NEUTRAL_GLASS_NAVBAR_DISPLACEMENT_MAP') expect(filterDefinitions).toContain('getBoundingClientRect()') @@ -447,20 +473,24 @@ describe('glass overlay material styles', () => { expect(filterDefinitions).toContain('in2="map"') expect(filterDefinitions).toContain(':width="displacementMapSize.width"') expect(filterDefinitions).toContain(':height="displacementMapSize.height"') - expect(filterDefinitions).not.toContain('width="100%"') - expect(filterDefinitions).not.toContain('height="100%"') + expect(filterDefinitions).not.toMatch(/]*\bwidth="100%"/u) + expect(filterDefinitions).not.toMatch(/]*\bheight="100%"/u) expect(filterDefinitions).toContain('scale="-22"') expect(filterDefinitions).toContain('scale="-34"') - expect(filterDefinitions).toContain('x="-8%"') - expect(filterDefinitions).toContain('width="116%"') - expect(filterDefinitions).toContain('x="-12%"') - expect(filterDefinitions).toContain('width="124%"') + expect(filterDefinitions).toContain('x="0%"') + expect(filterDefinitions).toContain('y="0%"') + expect(filterDefinitions).toContain('width="100%"') + 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(' { expect(field.height).toBe(41) expect(pixelAt(field, 50, 0)).toEqual([128, 128, 128, 255]) expect(pixelAt(field, 50, 20)).toEqual([128, 128, 128, 255]) - expect(pixelAt(field, 50, 5)[2]).toBeLessThan(64) - expect(pixelAt(field, 5, 20)[0]).toBeLessThan(64) - expect(pixelAt(field, 95, 20)[0]).toBeGreaterThan(192) - expect(pixelAt(field, 50, 35)[2]).toBeGreaterThan(192) + expect(pixelAt(field, 50, 5)[2]).toBeLessThan(120) + expect(pixelAt(field, 5, 20)[0]).toBeLessThan(120) + expect(pixelAt(field, 95, 20)[0]).toBeGreaterThan(136) + 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', () => { diff --git a/src/utils/glassNavbarRefraction.ts b/src/utils/glassNavbarRefraction.ts index 9aee0741..f5312a20 100644 --- a/src/utils/glassNavbarRefraction.ts +++ b/src/utils/glassNavbarRefraction.ts @@ -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' const DISPLACEMENT_NEUTRAL_CHANNEL = 128 -const DISPLACEMENT_CHANNEL_AMPLITUDE = 127 -const OUTER_NEUTRAL_GUARD_PX = 2 -const REFRACTION_BAND_PX = 12 -const REFRACTION_PROFILE_POWER = 2 +const HIGH_REFRACTION_SCALE_PX = 34 +const OUTER_NEUTRAL_GUARD_PX = 0.5 +const REFRACTION_BAND_PX = 24 +// 峰值靠近外沿,内侧有足够距离释放放大率;对称波峰会在窄轮廓内反向采样。 +const MAX_DISPLACEMENT_BAND_RATIO = 0.42 +const PEAK_DEPTH_RATIO = 0.16 function normalizePixelSize(value: number) { 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))) } +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 maxRadius = Math.min(pixelWidth, pixelHeight) / 2 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 outerGuard = Math.min(OUTER_NEUTRAL_GUARD_PX, bandWidth / 4) + const maximumBand = Math.min(REFRACTION_BAND_PX, pixelRadius * 1.5, Math.min(pixelWidth, pixelHeight) / 2) const pixels = new Uint8ClampedArray(pixelWidth * pixelHeight * 4) for (let offset = 0; offset < pixels.length; offset += 4) { @@ -83,11 +95,18 @@ export function createGlassNavbarDisplacementField({ const sampleY = y + 0.5 const signedDistance = roundedRectangleSignedDistance(sampleX, sampleY, pixelWidth, pixelHeight, pixelRadius) const distanceInside = -signedDistance + // 长直边允许更厚的透镜;向圆角与法线交汇轴渐缩,避免高曲率区产生聚焦尖点。 + const edgeX = Math.min(sampleX, pixelWidth - sampleX) + const edgeY = Math.min(sampleY, pixelHeight - sampleY) + const straightWeight = smoothstep(Math.min(1, Math.abs(edgeX - edgeY) / (maximumBand * 2 || 1))) + const cornerBand = Math.min(maximumBand, pixelRadius) + const bandWidth = 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 - const normalizedDistance = (distanceInside - outerGuard) / (bandWidth - outerGuard) - const profile = Math.sin(Math.PI * normalizedDistance) ** REFRACTION_PROFILE_POWER + const profile = refractionProfile(distanceInside, bandWidth, outerGuard) const gradientX = 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 pixels[offset] = clampChannel( - DISPLACEMENT_NEUTRAL_CHANNEL + (DISPLACEMENT_CHANNEL_AMPLITUDE * gradientX * profile) / gradientLength, + DISPLACEMENT_NEUTRAL_CHANNEL + (channelAmplitude * gradientX * profile) / gradientLength, ) pixels[offset + 2] = clampChannel( - DISPLACEMENT_NEUTRAL_CHANNEL + (DISPLACEMENT_CHANNEL_AMPLITUDE * gradientY * profile) / gradientLength, + DISPLACEMENT_NEUTRAL_CHANNEL + (channelAmplitude * gradientY * profile) / gradientLength, ) } }