feat(glass): anchor parameter-driven navbar POC

This commit is contained in:
InfinityPacer
2026-09-06 02:28:22 +08:00
parent 59d9244f9e
commit de8b3412ef
6 changed files with 304 additions and 146 deletions
@@ -1,5 +1,18 @@
<script lang="ts" setup>
import { createGlassNavbarDisplacementMap, NEUTRAL_GLASS_NAVBAR_DISPLACEMENT_MAP } from '@/utils/glassNavbarRefraction'
import {
createGlassNavbarDisplacementMap,
getGlassNavbarOpticalResponse,
NEUTRAL_GLASS_NAVBAR_DISPLACEMENT_MAP,
} from '@/utils/glassNavbarRefraction'
import { useEffectiveGlassSettings } from '@/composables/useThemeCustomizer'
const settings = useEffectiveGlassSettings()
const opticalResponse = computed(() =>
getGlassNavbarOpticalResponse({
deformation: settings.value.glassDeformationStrength,
translation: settings.value.glassTranslationStrength,
}),
)
const DEFAULT_NAVBAR_GEOMETRY = {
height: 64,
@@ -94,7 +107,8 @@ async function syncDisplacementMap() {
const width = Math.max(1, Math.round(bounds.width))
const radius = Number.isFinite(borderRadius) ? borderRadius : DEFAULT_NAVBAR_GEOMETRY.radius
const geometryKey = `${width}:${height}:${radius}`
const optics = opticalResponse.value
const geometryKey = `${width}:${height}:${radius}:${optics.horizontalRatio}:${optics.verticalRatio}:${optics.translationPx}`
if (lastObservedGeometry !== geometryKey) {
lastObservedGeometry = geometryKey
failedGeometry = ''
@@ -103,7 +117,7 @@ async function syncDisplacementMap() {
try {
if (cachedGeometry !== geometryKey) {
if (failedGeometry === geometryKey) return
const map = createGlassNavbarDisplacementMap({ height, radius, width })
const map = createGlassNavbarDisplacementMap({ height, radius, width, optics })
if (map === NEUTRAL_GLASS_NAVBAR_DISPLACEMENT_MAP) {
failedGeometry = geometryKey
invalidateDisplacementMap()
@@ -159,6 +173,9 @@ function handleGeometryTransition(event: TransitionEvent) {
}
}
// 草稿预览和取消复用同一有效参数源,旧异步解码不可覆盖新的滑杆值。
watch(opticalResponse, scheduleDisplacementMapSync, { flush: 'sync' })
onMounted(() => {
observedNavbar = document.querySelector('.layout-wrapper[data-glass-navbar-refraction="chromium"] .layout-navbar')
if (!observedNavbar) return
@@ -2,11 +2,15 @@ import { mount, flushPromises } from '@vue/test-utils'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import GlassNavbarRefractionDefs from '../GlassNavbarRefractionDefs.vue'
import { createGlassNavbarDisplacementMap } from '@/utils/glassNavbarRefraction'
import { ref } from 'vue'
vi.mock('@/utils/glassNavbarRefraction', () => ({
vi.mock('@/utils/glassNavbarRefraction', async importOriginal => ({
...(await importOriginal<typeof import('@/utils/glassNavbarRefraction')>()),
createGlassNavbarDisplacementMap: vi.fn(() => 'data:image/png;base64,test'),
NEUTRAL_GLASS_NAVBAR_DISPLACEMENT_MAP: 'neutral',
}))
const effectiveSettings = ref({ glassDeformationStrength: 48, glassTranslationStrength: 48 })
vi.mock('@/composables/useThemeCustomizer', () => ({ useEffectiveGlassSettings: () => effectiveSettings }))
describe('GlassNavbarRefractionDefs', () => {
let shell: HTMLDivElement
@@ -24,6 +28,7 @@ describe('GlassNavbarRefractionDefs', () => {
beforeEach(() => {
vi.useFakeTimers()
vi.clearAllMocks()
effectiveSettings.value = { glassDeformationStrength: 48, glassTranslationStrength: 48 }
width = 1423
radius = 16
transparencyReduced = false
@@ -121,7 +126,9 @@ describe('GlassNavbarRefractionDefs', () => {
wrapper = mount(GlassNavbarRefractionDefs)
expect(shell.dataset.glassNavbarRefractionReady).toBe('false')
await settle()
expect(createGlassNavbarDisplacementMap).toHaveBeenCalledWith({ width: 1423, height: 64, radius: 16 })
expect(createGlassNavbarDisplacementMap).toHaveBeenCalledWith(
expect.objectContaining({ width: 1423, height: 64, radius: 16 }),
)
expectReadyForWidth(1423)
})
@@ -135,7 +142,9 @@ describe('GlassNavbarRefractionDefs', () => {
width = 1200
resize?.([], {} as ResizeObserver)
await settle()
expect(createGlassNavbarDisplacementMap).toHaveBeenLastCalledWith({ width: 1200, height: 64, radius: 16 })
expect(createGlassNavbarDisplacementMap).toHaveBeenLastCalledWith(
expect.objectContaining({ width: 1200, height: 64, radius: 16 }),
)
expectReadyForWidth(1200)
})
@@ -147,6 +156,22 @@ describe('GlassNavbarRefractionDefs', () => {
expect(shell.dataset.glassNavbarRefractionReady).toBe('false')
})
it('rebuilds on effective preview parameters and restores the cancelled draft', async () => {
wrapper = mount(GlassNavbarRefractionDefs)
await settle()
effectiveSettings.value = { glassDeformationStrength: 0, glassTranslationStrength: 100 }
expect(shell.dataset.glassNavbarRefractionReady).toBe('false')
await settle()
expect(createGlassNavbarDisplacementMap).toHaveBeenLastCalledWith(
expect.objectContaining({ optics: { horizontalRatio: 0, verticalRatio: 0, translationPx: 17 } }),
)
effectiveSettings.value = { glassDeformationStrength: 48, glassTranslationStrength: 48 }
await settle()
expect(createGlassNavbarDisplacementMap).toHaveBeenLastCalledWith(
expect.objectContaining({ optics: expect.objectContaining({ translationPx: 8.16 }) }),
)
})
it('does not activate a pending map after switching to CSS quality', async () => {
wrapper = mount(GlassNavbarRefractionDefs)
await vi.advanceTimersByTimeAsync(65)
@@ -181,14 +206,18 @@ describe('GlassNavbarRefractionDefs', () => {
expect(shell.dataset.glassNavbarRefractionReady).toBe('false')
dispatchTransition('transitionend', 'width')
await settle()
expect(createGlassNavbarDisplacementMap).toHaveBeenLastCalledWith({ width: 1200, height: 64, radius: 16 })
expect(createGlassNavbarDisplacementMap).toHaveBeenLastCalledWith(
expect.objectContaining({ width: 1200, height: 64, radius: 16 }),
)
expectReadyForWidth(1200)
radius = 20
dispatchTransition('transitionrun', 'border-radius')
dispatchTransition('transitioncancel', 'border-radius')
await settle()
expect(createGlassNavbarDisplacementMap).toHaveBeenLastCalledWith({ width: 1200, height: 64, radius: 20 })
expect(createGlassNavbarDisplacementMap).toHaveBeenLastCalledWith(
expect.objectContaining({ width: 1200, height: 64, radius: 20 }),
)
expectReadyForWidth(1200)
})
@@ -221,7 +250,9 @@ describe('GlassNavbarRefractionDefs', () => {
shell.style.setProperty('--shell-floating-navbar-radius', '20px')
await flushPromises()
await settle()
expect(createGlassNavbarDisplacementMap).toHaveBeenLastCalledWith({ width: 1423, height: 64, radius: 20 })
expect(createGlassNavbarDisplacementMap).toHaveBeenLastCalledWith(
expect.objectContaining({ width: 1423, height: 64, radius: 20 }),
)
})
it('does not retry a failed geometry in a feedback loop', async () => {
@@ -248,7 +279,9 @@ describe('GlassNavbarRefractionDefs', () => {
width = 1200
window.dispatchEvent(new Event('resize'))
await settle()
expect(createGlassNavbarDisplacementMap).toHaveBeenLastCalledWith({ width: 1200, height: 64, radius: 16 })
expect(createGlassNavbarDisplacementMap).toHaveBeenLastCalledWith(
expect.objectContaining({ width: 1200, height: 64, radius: 16 }),
)
wrapper.unmount()
wrapper = undefined
@@ -395,67 +395,71 @@ describe('glass overlay material styles', () => {
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 tintedNavbarStart = styles.indexOf(
"html[data-theme='glass'][data-glass-appearance='tinted']",
svgEnhancementStart,
)
const baseMaterialRule = styles.slice(baseMaterialStart, svgEnhancementStart)
const svgFilterRule = styles.slice(svgEnhancementStart, tintedNavbarStart)
const reducedTransparencyRule = styles.slice(reducedTransparencyStart, reducedMotionStart)
const reducedMotionRule = styles.slice(reducedMotionStart)
expect(styles).toContain("data-glass-navbar-refraction='chromium'")
expect(styles).toContain("url('#glass-navbar-live-refraction-balanced')")
expect(styles).toContain("url('#glass-navbar-live-refraction-high')")
expect(styles).toMatch(
/:is\(\[data-glass-appearance='clear'\], \[data-glass-appearance='tinted'\]\)[\s\S]*?\.layout-navbar-floating-eligible\.layout-navbar-away-from-top[\s\S]*?\.layout-navbar/,
)
expect(styles).toContain('inline-size: auto !important')
expect(styles).toContain('inset-block-start: 0 !important')
expect(styles).toContain('inset-inline: 0 !important')
expect(styles).toContain('inset-block-start: var(--shell-floating-navbar-inset) !important')
expect(styles).toContain('inset-inline: var(--shell-floating-navbar-inset) !important')
expect(styles).toContain('transform: none !important')
expect(styles).toContain(
'inset-block-start var(--shell-floating-navbar-motion-duration) var(--shell-floating-navbar-motion-easing)',
)
expect(styles).toContain(
'inset-inline-end var(--shell-floating-navbar-motion-duration) var(--shell-floating-navbar-motion-easing)',
)
expect(styles).toContain(
'inset-inline-start var(--shell-floating-navbar-motion-duration) var(--shell-floating-navbar-motion-easing)',
)
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("[data-glass-appearance='clear'], [data-glass-appearance='tinted']")
expect(baseMaterialRule).toContain('--glass-navbar-opacity: clamp(')
expect(baseMaterialRule).toContain(
'--glass-navbar-reflection-ratio: clamp(0, calc(var(--glass-reflection, 0.38) * 2.6316), 1.7)',
)
expect(baseMaterialRule).toContain(
'--glass-navbar-sheen-opacity: clamp(0, calc(0.18 * var(--glass-navbar-reflection-ratio)), 0.3)',
)
expect(baseMaterialRule).toContain(
'--glass-navbar-rim-opacity: clamp(0, calc(0.24 * var(--glass-navbar-reflection-ratio)), 0.4)',
)
expect(baseMaterialRule).toContain(
'--glass-navbar-top-opacity: clamp(0, calc(0.4 * var(--glass-navbar-reflection-ratio)), 0.65)',
)
expect(baseMaterialRule).toContain('var(--glass-background-visibility, 0.58)')
expect(baseMaterialRule).toContain('var(--glass-surface-density, 0.62)')
expect(baseMaterialRule).toContain('--glass-navbar-blur: clamp(')
expect(baseMaterialRule).toContain('0.62px + var(--glass-surface-density, 0.62) * 0.8px')
expect(baseMaterialRule).toContain('- var(--glass-background-visibility, 0.58) * 0.25px')
expect(baseMaterialRule).toContain('--glass-navbar-brightness: var(--glass-transmission-brightness, 1)')
expect(baseMaterialRule).toContain('--glass-navbar-saturation: clamp(')
expect(baseMaterialRule).toContain('--glass-navbar-sheen: linear-gradient(')
expect(baseMaterialRule).toContain('var(--glass-navbar-reflection-ratio)')
expect(baseMaterialRule).toContain('--glass-navbar-scrim: linear-gradient(')
expect(baseMaterialRule).toContain(
'--glass-navbar-tint: clamp(0, calc(var(--glass-tint-density, 0.65) * 0.12), 0.18)',
)
expect(baseMaterialRule).toContain('--glass-navbar-live-filter: blur(var(--glass-navbar-blur))')
expect(baseMaterialRule).toContain('brightness(var(--glass-navbar-brightness))')
expect(baseMaterialRule).toContain('background: var(--glass-navbar-sheen), var(--glass-navbar-scrim) !important')
expect(baseMaterialRule).toContain('box-shadow: var(--glass-navbar-shadow) !important')
expect(baseMaterialRule).toContain('border: 0 !important')
expect(baseMaterialRule).toContain('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,
)
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(baseMaterialRule).toContain('inset-block-start: var(--shell-floating-navbar-inset) !important')
expect(baseMaterialRule).toContain('inset-inline: var(--shell-floating-navbar-inset) !important')
expect(baseMaterialRule).not.toContain('data-glass-navbar-refraction-ready')
expect(baseMaterialRule).not.toContain('&::before')
expect(styles).not.toContain('&::after')
expect(styles).not.toContain("[data-glass-appearance='frosted'][data-glass-navbar-refraction='chromium']")
expect(baseMaterialRule).not.toContain('&::after')
expect(baseMaterialRule).not.toContain("url('#glass-navbar-live-refraction-")
expect(svgFilterRule).toContain("data-glass-navbar-refraction-ready='true'")
expect(svgFilterRule).toContain("url('#glass-navbar-live-refraction-balanced')")
expect(svgFilterRule).toContain("url('#glass-navbar-live-refraction-high')")
expect(svgFilterRule).toContain('blur(var(--glass-navbar-blur))')
expect(svgFilterRule).toContain('saturate(var(--glass-navbar-saturation))')
expect(svgFilterRule).toContain('brightness(var(--glass-navbar-brightness))')
expect(svgFilterRule).not.toContain('background:')
expect(svgFilterRule).not.toContain('box-shadow:')
expect(svgFilterRule).not.toContain('border:')
expect(tintedNavbarStart).toBeGreaterThan(svgEnhancementStart)
expect(styles.slice(tintedNavbarStart, reducedTransparencyStart)).toContain('var(--glass-material-accent-rgb)')
expect(styles.slice(tintedNavbarStart, reducedTransparencyStart)).toContain('var(--glass-navbar-tint)')
expect(reducedTransparencyRule).toContain('--glass-navbar-live-filter: none !important')
expect(reducedTransparencyRule).toContain('-webkit-backdrop-filter: none !important')
expect(reducedTransparencyRule).toContain('backdrop-filter: none !important')
@@ -466,31 +470,5 @@ describe('glass overlay material styles', () => {
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()')
expect(filterDefinitions).toContain('new ResizeObserver(scheduleDisplacementMapSync)')
expect(filterDefinitions).toContain('in2="map"')
expect(filterDefinitions).toContain(':width="displacementMapSize.width"')
expect(filterDefinitions).toContain(':height="displacementMapSize.height"')
expect(filterDefinitions).not.toMatch(/<feImage\b[^>]*\bwidth="100%"/u)
expect(filterDefinitions).not.toMatch(/<feImage\b[^>]*\bheight="100%"/u)
expect(filterDefinitions).toContain('scale="-22"')
expect(filterDefinitions).toContain('scale="-34"')
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('<feComposite')
expect(filterDefinitions).not.toContain('<feGaussianBlur')
expect(refractionUtilities).toContain('createGlassNavbarDisplacementField')
expect(refractionUtilities).toContain('OUTER_NEUTRAL_GUARD_PX')
expect(refractionUtilities).toContain('refractionProfile(distanceInside, bandWidth, outerGuard)')
expect(refractionUtilities).toContain("canvas.toDataURL('image/png')")
})
})
+57 -14
View File
@@ -1686,19 +1686,57 @@ html[data-theme='glass']:is([data-glass-appearance='clear'], [data-glass-appeara
body[data-theme='glass']
.layout-wrapper.layout-navbar-floating-eligible.layout-navbar-away-from-top
.layout-navbar {
--glass-navbar-live-filter: blur(1.5px) saturate(118%);
// 共享材质响应只改变浮动表面的光学参数,不把内容层一起变暗或变透明。
// 0.38 是 Natural clear/high 的反射参考值,三个亮边在该点保持默认材质能量。
--glass-navbar-reflection-ratio: clamp(0, calc(var(--glass-reflection, 0.38) * 2.6316), 1.7);
--glass-navbar-sheen-opacity: clamp(0, calc(0.18 * var(--glass-navbar-reflection-ratio)), 0.3);
--glass-navbar-rim-opacity: clamp(0, calc(0.24 * var(--glass-navbar-reflection-ratio)), 0.4);
--glass-navbar-top-opacity: clamp(0, calc(0.4 * var(--glass-navbar-reflection-ratio)), 0.65);
--glass-navbar-opacity: clamp(
0,
calc(0.02 + var(--glass-surface-density, 0.62) * 0.05 + (1 - var(--glass-background-visibility, 0.58)) * 0.02),
0.2
);
--glass-navbar-scrim-end: clamp(
0,
calc(0.045 + var(--glass-surface-density, 0.62) * 0.1 + (1 - var(--glass-background-visibility, 0.58)) * 0.05),
0.28
);
--glass-navbar-blur: clamp(
0.35px,
calc(0.62px + var(--glass-surface-density, 0.62) * 0.8px - var(--glass-background-visibility, 0.58) * 0.25px),
1.4px
);
--glass-navbar-brightness: var(--glass-transmission-brightness, 1);
--glass-navbar-saturation: clamp(
110%,
calc(110% + var(--glass-background-visibility, 0.58) * 12% + var(--glass-tint-density, 0.65) * 4%),
124%
);
--glass-navbar-sheen: linear-gradient(
145deg,
rgba(255, 255, 255, var(--glass-navbar-sheen-opacity)),
transparent 38%
);
--glass-navbar-rim: rgba(255, 255, 255, var(--glass-navbar-rim-opacity));
--glass-navbar-scrim: linear-gradient(
rgba(7, 14, 25, var(--glass-navbar-opacity)),
rgba(7, 14, 25, var(--glass-navbar-scrim-end))
);
--glass-navbar-tint: clamp(0, calc(var(--glass-tint-density, 0.65) * 0.12), 0.18);
--glass-navbar-shadow:
inset 0 0 0 1px var(--glass-navbar-rim), inset 0 1px 2px rgba(255, 255, 255, var(--glass-navbar-top-opacity)),
inset 0 -1px 2px rgba(4, 10, 20, calc(0.1 + var(--glass-surface-density, 0.62) * 0.12)),
0 12px 32px
rgba(3, 7, 18, calc(0.08 + var(--glass-reflection, 0.5) * 0.1 + var(--glass-surface-density, 0.62) * 0.08));
--glass-navbar-live-filter: blur(var(--glass-navbar-blur)) saturate(var(--glass-navbar-saturation))
brightness(var(--glass-navbar-brightness));
border: 0 !important;
-webkit-backdrop-filter: var(--glass-navbar-live-filter) !important;
backdrop-filter: var(--glass-navbar-live-filter) !important;
background:
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;
box-shadow:
inset 0 0 0 1px rgba(255, 255, 255, 0.24),
inset 0 1px 2px rgba(255, 255, 255, 0.4),
inset 0 -1px 2px rgba(4, 10, 20, 0.22),
0 12px 32px rgba(3, 7, 18, 0.16) !important;
background: var(--glass-navbar-sheen), var(--glass-navbar-scrim) !important;
box-shadow: var(--glass-navbar-shadow) !important;
inset-block-start: var(--shell-floating-navbar-inset) !important;
inset-inline: var(--shell-floating-navbar-inset) !important;
}
@@ -1711,7 +1749,8 @@ html[data-theme='glass'][data-glass-quality='high']:is(
body[data-theme='glass']
.layout-wrapper[data-glass-navbar-refraction='chromium'][data-glass-navbar-refraction-ready='true'].layout-navbar-floating-eligible.layout-navbar-away-from-top
.layout-navbar {
--glass-navbar-live-filter: url('#glass-navbar-live-refraction-high') blur(1px) saturate(122%);
--glass-navbar-live-filter: url('#glass-navbar-live-refraction-high') blur(var(--glass-navbar-blur))
saturate(var(--glass-navbar-saturation)) brightness(var(--glass-navbar-brightness));
}
html[data-theme='glass'][data-glass-quality='balanced']:is(
@@ -1721,7 +1760,8 @@ html[data-theme='glass'][data-glass-quality='balanced']:is(
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%);
--glass-navbar-live-filter: url('#glass-navbar-live-refraction-balanced') blur(var(--glass-navbar-blur))
saturate(var(--glass-navbar-saturation)) brightness(var(--glass-navbar-brightness));
}
html[data-theme='glass'][data-glass-appearance='tinted']
@@ -1729,9 +1769,12 @@ html[data-theme='glass'][data-glass-appearance='tinted']
.layout-wrapper.layout-navbar-floating-eligible.layout-navbar-away-from-top
.layout-navbar {
background:
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(7, 14, 25, 0.08), rgba(7, 14, 25, 0.15)) !important;
var(--glass-navbar-sheen),
linear-gradient(
rgba(var(--glass-material-accent-rgb), var(--glass-navbar-tint)),
rgba(var(--glass-material-accent-rgb), calc(var(--glass-navbar-tint) * 0.45))
),
var(--glass-navbar-scrim) !important;
}
@media (prefers-reduced-transparency: reduce) {
@@ -1,5 +1,41 @@
import { describe, expect, it } from 'vitest'
import { createGlassNavbarDisplacementField, supportsGlassNavbarLiveRefraction } from '@/utils/glassNavbarRefraction'
import {
createGlassNavbarDisplacementField,
getGlassNavbarOpticalResponse,
supportsGlassNavbarLiveRefraction,
} from '@/utils/glassNavbarRefraction'
describe('getGlassNavbarOpticalResponse', () => {
it('keeps default vertical deformation small while retaining substantial translation', () => {
const optics = getGlassNavbarOpticalResponse({ deformation: 48, translation: 48 })
expect(optics.translationPx).toBeCloseTo(8.16)
expect(optics.verticalRatio * 24).toBeLessThan(0.4)
expect(optics.horizontalRatio * 24).toBeGreaterThan(3)
})
it.each([
[0, 0],
[50, 8.5],
[99, 16.83],
[100, 17],
])('maps translation %s linearly to %s pixels', (translation, pixels) => {
expect(getGlassNavbarOpticalResponse({ deformation: 48, translation }).translationPx).toBeCloseTo(pixels)
})
it('separates translation from deformation and limits the maximum translation', () => {
expect(getGlassNavbarOpticalResponse({ deformation: 0, translation: 100 })).toEqual({
horizontalRatio: 0,
verticalRatio: 0,
translationPx: 17,
})
expect(getGlassNavbarOpticalResponse({ deformation: 100, translation: 0 })).toEqual({
horizontalRatio: 0.42,
verticalRatio: 0.055,
translationPx: 0,
})
expect(getGlassNavbarOpticalResponse({ deformation: -30, translation: 300 }).translationPx).toBe(17)
expect(getGlassNavbarOpticalResponse({ deformation: 48, translation: 99 }).translationPx).toBeGreaterThan(16)
expect(getGlassNavbarOpticalResponse({ deformation: 48, translation: 99 }).translationPx).toBeLessThan(17)
})
})
describe('createGlassNavbarDisplacementField', () => {
function pixelAt(field: ReturnType<typeof createGlassNavbarDisplacementField>, x: number, y: number) {
@@ -9,7 +45,12 @@ describe('createGlassNavbarDisplacementField', () => {
}
it('keeps both contour boundary and interior neutral while bending only the narrow rim', () => {
const field = createGlassNavbarDisplacementField({ height: 41, radius: 12, width: 101 })
const field = createGlassNavbarDisplacementField({
height: 41,
radius: 12,
width: 101,
optics: getGlassNavbarOpticalResponse({ deformation: 100, translation: 0 }),
})
expect(field.width).toBe(101)
expect(field.height).toBe(41)
@@ -27,8 +68,13 @@ describe('createGlassNavbarDisplacementField', () => {
{ 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 deformation of [0, 48, 100])
for (const translation of [0, 48, 100])
for (const scale of [-22, -34]) {
const field = createGlassNavbarDisplacementField({
...geometry,
optics: getGlassNavbarOpticalResponse({ deformation, translation }),
})
const source = (x: number, y: number) => {
const pixel = pixelAt(field, x, y)
return [x + 0.5 + scale * (pixel[0] / 255 - 0.5), y + 0.5 + scale * (pixel[2] / 255 - 0.5)]
@@ -84,10 +130,10 @@ describe('createGlassNavbarDisplacementField', () => {
maximumVerticalStep = Math.max(maximumVerticalStep, 1 + current - previous)
}
for (let x = 0; x < 32; x += 1) maximumHorizontal = Math.max(maximumHorizontal, Math.abs(displacement(x, 32, 0)))
expect(maximumHorizontal).toBeGreaterThan(7)
expect(maximumVertical).toBeLessThan(2.6)
expect(minimumVerticalStep).toBeGreaterThan(0.59)
expect(maximumVerticalStep).toBeLessThan(1.41)
expect(maximumHorizontal).toBeGreaterThan(1.5)
expect(maximumVertical).toBeLessThan(0.5)
expect(minimumVerticalStep).toBeGreaterThan(0.85)
expect(maximumVerticalStep).toBeLessThan(1.15)
})
})
+51 -10
View File
@@ -1,3 +1,9 @@
import {
getGlassOpticalPresetParameters,
normalizeGlassOpticalStrength,
type GlassOpticalParameters,
} from '@/utils/glassOptics'
export interface GlassNavbarRefractionBrowserIdentity {
/** User-Agent Client Hints 暴露的浏览器品牌。 */
userAgentData?: {
@@ -16,6 +22,32 @@ export interface GlassNavbarDisplacementGeometry {
radius: number
/** 折射表面的实际 CSS 像素宽度。 */
width: number
/** 由当前生效滑杆计算的顶栏光学响应;省略时采用清透自然默认值。 */
optics?: GlassNavbarOpticalResponse
}
/** 顶栏局部取样预算,不包含共享 renderer 的流动、尾波与惯性。 */
export interface GlassNavbarOpticalResponse {
/** 横向边缘峰值位移与轮廓带宽之比。 */
horizontalRatio: number
/** 纵向峰值位移与轮廓带宽之比,严格小于横向以保护字形高度。 */
verticalRatio: number
/** 主体内容统一向右显示的 CSS 像素偏移,外轮廓平缓回零。 */
translationPx: number
}
/** 自然默认值即呈现明显短边折射;纵向形变与平移独立限幅以保护阅读。 */
export function getGlassNavbarOpticalResponse(
parameters: Pick<GlassOpticalParameters, 'deformation' | 'translation'>,
): GlassNavbarOpticalResponse {
const deformation = normalizeGlassOpticalStrength(parameters.deformation) / 100
const translation = normalizeGlassOpticalStrength(parameters.translation) / 100
return {
horizontalRatio: 0.42 * (1 - (1 - deformation) ** 3),
verticalRatio: 0.055 * deformation ** 2,
// 平移使用线性刻度,50% 对应 8.5px;不把平移强度叠加到字形纵向缩放。
translationPx: 17 * translation,
}
}
export interface GlassNavbarDisplacementField {
@@ -35,10 +67,8 @@ 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
// 文本沿纵向滚动穿过长边时,限制字形高度的局部伸缩;短边保留更强的水平透镜。
const VERTICAL_DISPLACEMENT_BAND_RATIO = 0.1
const DEFAULT_NAVBAR_OPTICS = getGlassNavbarOpticalResponse(getGlassOpticalPresetParameters('clear', 'high', 'natural'))
function normalizePixelSize(value: number) {
return Number.isFinite(value) ? Math.max(1, Math.round(value)) : 1
@@ -70,12 +100,13 @@ function refractionProfile(depth: number, band: number, guard: number) {
/**
* 生成圆角表面的法线位移场。
* 外轮廓和内区都保持中性采样,避免折射在裁剪边界或主体内容区形成整带错位
* 外轮廓回到中性;内部统一平移与局部透镜分开计算,不用弯曲字形代替平移
*/
export function createGlassNavbarDisplacementField({
height,
radius,
width,
optics = DEFAULT_NAVBAR_OPTICS,
}: GlassNavbarDisplacementGeometry): GlassNavbarDisplacementField {
const pixelWidth = normalizePixelSize(width)
const pixelHeight = normalizePixelSize(height)
@@ -104,14 +135,20 @@ export function createGlassNavbarDisplacementField({
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
const channelAmplitude = (bandWidth * optics.horizontalRatio * 255) / HIGH_REFRACTION_SCALE_PX
if (signedDistance > 0 || distanceInside <= outerGuard || distanceInside >= bandWidth) continue
if (
signedDistance > 0 ||
distanceInside <= outerGuard ||
bandWidth <= outerGuard ||
Math.min(pixelWidth, pixelHeight) < 4
)
continue
const profile = refractionProfile(distanceInside, bandWidth, outerGuard)
const verticalProgress = (distanceInside - outerGuard) / (bandWidth - outerGuard)
const profile = distanceInside < bandWidth ? refractionProfile(distanceInside, bandWidth, outerGuard) : 0
const verticalProgress = Math.min(1, (distanceInside - outerGuard) / (bandWidth - outerGuard))
const verticalProfile = Math.sin(Math.PI * verticalProgress) ** 2
const verticalAmplitude = (bandWidth * VERTICAL_DISPLACEMENT_BAND_RATIO * 255) / HIGH_REFRACTION_SCALE_PX
const verticalAmplitude = (bandWidth * optics.verticalRatio * 255) / HIGH_REFRACTION_SCALE_PX
const gradientX =
roundedRectangleSignedDistance(sampleX + 0.5, sampleY, pixelWidth, pixelHeight, pixelRadius) -
roundedRectangleSignedDistance(sampleX - 0.5, sampleY, pixelWidth, pixelHeight, pixelRadius)
@@ -120,9 +157,13 @@ export function createGlassNavbarDisplacementField({
roundedRectangleSignedDistance(sampleX, sampleY - 0.5, pixelWidth, pixelHeight, pixelRadius)
const gradientLength = Math.hypot(gradientX, gradientY) || 1
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(
DISPLACEMENT_NEUTRAL_CHANNEL + (channelAmplitude * gradientX * profile) / gradientLength,
DISPLACEMENT_NEUTRAL_CHANNEL + (channelAmplitude * gradientX * profile) / gradientLength + translationChannel,
)
pixels[offset + 2] = clampChannel(
DISPLACEMENT_NEUTRAL_CHANNEL + (verticalAmplitude * gradientY * verticalProfile) / gradientLength,