mirror of
https://github.com/jxxghp/MoviePilot-Frontend.git
synced 2026-09-10 10:16:44 +08:00
feat(glass): anchor parameter-driven navbar POC
This commit is contained in:
@@ -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')")
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user