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>
|
<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 = {
|
const DEFAULT_NAVBAR_GEOMETRY = {
|
||||||
height: 64,
|
height: 64,
|
||||||
@@ -94,7 +107,8 @@ async function syncDisplacementMap() {
|
|||||||
const width = Math.max(1, Math.round(bounds.width))
|
const width = Math.max(1, Math.round(bounds.width))
|
||||||
|
|
||||||
const radius = Number.isFinite(borderRadius) ? borderRadius : DEFAULT_NAVBAR_GEOMETRY.radius
|
const radius = Number.isFinite(borderRadius) ? borderRadius : 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) {
|
if (lastObservedGeometry !== geometryKey) {
|
||||||
lastObservedGeometry = geometryKey
|
lastObservedGeometry = geometryKey
|
||||||
failedGeometry = ''
|
failedGeometry = ''
|
||||||
@@ -103,7 +117,7 @@ async function syncDisplacementMap() {
|
|||||||
try {
|
try {
|
||||||
if (cachedGeometry !== geometryKey) {
|
if (cachedGeometry !== geometryKey) {
|
||||||
if (failedGeometry === geometryKey) return
|
if (failedGeometry === geometryKey) return
|
||||||
const map = createGlassNavbarDisplacementMap({ height, radius, width })
|
const map = createGlassNavbarDisplacementMap({ height, radius, width, optics })
|
||||||
if (map === NEUTRAL_GLASS_NAVBAR_DISPLACEMENT_MAP) {
|
if (map === NEUTRAL_GLASS_NAVBAR_DISPLACEMENT_MAP) {
|
||||||
failedGeometry = geometryKey
|
failedGeometry = geometryKey
|
||||||
invalidateDisplacementMap()
|
invalidateDisplacementMap()
|
||||||
@@ -159,6 +173,9 @@ function handleGeometryTransition(event: TransitionEvent) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 草稿预览和取消复用同一有效参数源,旧异步解码不可覆盖新的滑杆值。
|
||||||
|
watch(opticalResponse, scheduleDisplacementMapSync, { flush: 'sync' })
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
observedNavbar = document.querySelector('.layout-wrapper[data-glass-navbar-refraction="chromium"] .layout-navbar')
|
observedNavbar = document.querySelector('.layout-wrapper[data-glass-navbar-refraction="chromium"] .layout-navbar')
|
||||||
if (!observedNavbar) return
|
if (!observedNavbar) return
|
||||||
|
|||||||
@@ -2,11 +2,15 @@ import { mount, flushPromises } from '@vue/test-utils'
|
|||||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
import GlassNavbarRefractionDefs from '../GlassNavbarRefractionDefs.vue'
|
import GlassNavbarRefractionDefs from '../GlassNavbarRefractionDefs.vue'
|
||||||
import { createGlassNavbarDisplacementMap } from '@/utils/glassNavbarRefraction'
|
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'),
|
createGlassNavbarDisplacementMap: vi.fn(() => 'data:image/png;base64,test'),
|
||||||
NEUTRAL_GLASS_NAVBAR_DISPLACEMENT_MAP: 'neutral',
|
NEUTRAL_GLASS_NAVBAR_DISPLACEMENT_MAP: 'neutral',
|
||||||
}))
|
}))
|
||||||
|
const effectiveSettings = ref({ glassDeformationStrength: 48, glassTranslationStrength: 48 })
|
||||||
|
vi.mock('@/composables/useThemeCustomizer', () => ({ useEffectiveGlassSettings: () => effectiveSettings }))
|
||||||
|
|
||||||
describe('GlassNavbarRefractionDefs', () => {
|
describe('GlassNavbarRefractionDefs', () => {
|
||||||
let shell: HTMLDivElement
|
let shell: HTMLDivElement
|
||||||
@@ -24,6 +28,7 @@ describe('GlassNavbarRefractionDefs', () => {
|
|||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.useFakeTimers()
|
vi.useFakeTimers()
|
||||||
vi.clearAllMocks()
|
vi.clearAllMocks()
|
||||||
|
effectiveSettings.value = { glassDeformationStrength: 48, glassTranslationStrength: 48 }
|
||||||
width = 1423
|
width = 1423
|
||||||
radius = 16
|
radius = 16
|
||||||
transparencyReduced = false
|
transparencyReduced = false
|
||||||
@@ -121,7 +126,9 @@ describe('GlassNavbarRefractionDefs', () => {
|
|||||||
wrapper = mount(GlassNavbarRefractionDefs)
|
wrapper = mount(GlassNavbarRefractionDefs)
|
||||||
expect(shell.dataset.glassNavbarRefractionReady).toBe('false')
|
expect(shell.dataset.glassNavbarRefractionReady).toBe('false')
|
||||||
await settle()
|
await settle()
|
||||||
expect(createGlassNavbarDisplacementMap).toHaveBeenCalledWith({ width: 1423, height: 64, radius: 16 })
|
expect(createGlassNavbarDisplacementMap).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({ width: 1423, height: 64, radius: 16 }),
|
||||||
|
)
|
||||||
expectReadyForWidth(1423)
|
expectReadyForWidth(1423)
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -135,7 +142,9 @@ describe('GlassNavbarRefractionDefs', () => {
|
|||||||
width = 1200
|
width = 1200
|
||||||
resize?.([], {} as ResizeObserver)
|
resize?.([], {} as ResizeObserver)
|
||||||
await settle()
|
await settle()
|
||||||
expect(createGlassNavbarDisplacementMap).toHaveBeenLastCalledWith({ width: 1200, height: 64, radius: 16 })
|
expect(createGlassNavbarDisplacementMap).toHaveBeenLastCalledWith(
|
||||||
|
expect.objectContaining({ width: 1200, height: 64, radius: 16 }),
|
||||||
|
)
|
||||||
expectReadyForWidth(1200)
|
expectReadyForWidth(1200)
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -147,6 +156,22 @@ describe('GlassNavbarRefractionDefs', () => {
|
|||||||
expect(shell.dataset.glassNavbarRefractionReady).toBe('false')
|
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 () => {
|
it('does not activate a pending map after switching to CSS quality', async () => {
|
||||||
wrapper = mount(GlassNavbarRefractionDefs)
|
wrapper = mount(GlassNavbarRefractionDefs)
|
||||||
await vi.advanceTimersByTimeAsync(65)
|
await vi.advanceTimersByTimeAsync(65)
|
||||||
@@ -181,14 +206,18 @@ describe('GlassNavbarRefractionDefs', () => {
|
|||||||
expect(shell.dataset.glassNavbarRefractionReady).toBe('false')
|
expect(shell.dataset.glassNavbarRefractionReady).toBe('false')
|
||||||
dispatchTransition('transitionend', 'width')
|
dispatchTransition('transitionend', 'width')
|
||||||
await settle()
|
await settle()
|
||||||
expect(createGlassNavbarDisplacementMap).toHaveBeenLastCalledWith({ width: 1200, height: 64, radius: 16 })
|
expect(createGlassNavbarDisplacementMap).toHaveBeenLastCalledWith(
|
||||||
|
expect.objectContaining({ width: 1200, height: 64, radius: 16 }),
|
||||||
|
)
|
||||||
expectReadyForWidth(1200)
|
expectReadyForWidth(1200)
|
||||||
|
|
||||||
radius = 20
|
radius = 20
|
||||||
dispatchTransition('transitionrun', 'border-radius')
|
dispatchTransition('transitionrun', 'border-radius')
|
||||||
dispatchTransition('transitioncancel', 'border-radius')
|
dispatchTransition('transitioncancel', 'border-radius')
|
||||||
await settle()
|
await settle()
|
||||||
expect(createGlassNavbarDisplacementMap).toHaveBeenLastCalledWith({ width: 1200, height: 64, radius: 20 })
|
expect(createGlassNavbarDisplacementMap).toHaveBeenLastCalledWith(
|
||||||
|
expect.objectContaining({ width: 1200, height: 64, radius: 20 }),
|
||||||
|
)
|
||||||
expectReadyForWidth(1200)
|
expectReadyForWidth(1200)
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -221,7 +250,9 @@ describe('GlassNavbarRefractionDefs', () => {
|
|||||||
shell.style.setProperty('--shell-floating-navbar-radius', '20px')
|
shell.style.setProperty('--shell-floating-navbar-radius', '20px')
|
||||||
await flushPromises()
|
await flushPromises()
|
||||||
await settle()
|
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 () => {
|
it('does not retry a failed geometry in a feedback loop', async () => {
|
||||||
@@ -248,7 +279,9 @@ describe('GlassNavbarRefractionDefs', () => {
|
|||||||
width = 1200
|
width = 1200
|
||||||
window.dispatchEvent(new Event('resize'))
|
window.dispatchEvent(new Event('resize'))
|
||||||
await settle()
|
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.unmount()
|
||||||
wrapper = undefined
|
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', () => {
|
it('keeps floating clear and tinted navbars on CSS material until Chromium SVG is ready', () => {
|
||||||
const styles = readFileSync(resolve(cwd(), 'src/styles/themes/glass.scss'), 'utf8')
|
const styles = readFileSync(resolve(cwd(), 'src/styles/themes/glass.scss'), 'utf8')
|
||||||
const filterDefinitions = readFileSync(resolve(cwd(), 'src/components/theme/GlassNavbarRefractionDefs.vue'), 'utf8')
|
|
||||||
const refractionUtilities = readFileSync(resolve(cwd(), 'src/utils/glassNavbarRefraction.ts'), 'utf8')
|
|
||||||
const baseMaterialStart = styles.lastIndexOf('// 基础材质由单一真实表面承载')
|
const baseMaterialStart = styles.lastIndexOf('// 基础材质由单一真实表面承载')
|
||||||
const svgEnhancementStart = styles.indexOf('// 只有已确认的 Chromium SVG 能力')
|
const svgEnhancementStart = styles.indexOf('// 只有已确认的 Chromium SVG 能力')
|
||||||
const reducedTransparencyStart = styles.lastIndexOf('@media (prefers-reduced-transparency: reduce)')
|
const reducedTransparencyStart = styles.lastIndexOf('@media (prefers-reduced-transparency: reduce)')
|
||||||
const reducedMotionStart = styles.lastIndexOf('@media (prefers-reduced-motion: 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 baseMaterialRule = styles.slice(baseMaterialStart, svgEnhancementStart)
|
||||||
|
const svgFilterRule = styles.slice(svgEnhancementStart, tintedNavbarStart)
|
||||||
const reducedTransparencyRule = styles.slice(reducedTransparencyStart, reducedMotionStart)
|
const reducedTransparencyRule = styles.slice(reducedTransparencyStart, reducedMotionStart)
|
||||||
const reducedMotionRule = styles.slice(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(baseMaterialStart).toBeGreaterThanOrEqual(0)
|
||||||
expect(svgEnhancementStart).toBeGreaterThan(baseMaterialStart)
|
expect(svgEnhancementStart).toBeGreaterThan(baseMaterialStart)
|
||||||
expect(baseMaterialRule).toContain('.layout-wrapper.layout-navbar-floating-eligible.layout-navbar-away-from-top')
|
expect(baseMaterialRule).toContain("[data-glass-appearance='clear'], [data-glass-appearance='tinted']")
|
||||||
expect(baseMaterialRule).not.toContain("data-glass-navbar-refraction='chromium'")
|
expect(baseMaterialRule).toContain('--glass-navbar-opacity: clamp(')
|
||||||
expect(styles).toContain('--glass-navbar-live-filter: none;')
|
expect(baseMaterialRule).toContain(
|
||||||
expect(baseMaterialRule).toContain('--glass-navbar-live-filter: blur(1.5px) saturate(118%)')
|
'--glass-navbar-reflection-ratio: clamp(0, calc(var(--glass-reflection, 0.38) * 2.6316), 1.7)',
|
||||||
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(
|
||||||
|
'--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('border: 0 !important')
|
||||||
expect(baseMaterialRule).toContain('linear-gradient(145deg, rgba(255, 255, 255, 0.18), transparent 38%)')
|
expect(baseMaterialRule).toContain('inset-block-start: var(--shell-floating-navbar-inset) !important')
|
||||||
expect(baseMaterialRule).toContain('inset 0 1px 2px rgba(255, 255, 255, 0.4)')
|
expect(baseMaterialRule).toContain('inset-inline: var(--shell-floating-navbar-inset) !important')
|
||||||
expect(baseMaterialRule).not.toContain('inset 0 1px 0 rgba(255, 255, 255, 0.4)')
|
expect(baseMaterialRule).not.toContain('data-glass-navbar-refraction-ready')
|
||||||
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).not.toContain('&::before')
|
expect(baseMaterialRule).not.toContain('&::before')
|
||||||
expect(styles).not.toContain('&::after')
|
expect(baseMaterialRule).not.toContain('&::after')
|
||||||
expect(styles).not.toContain("[data-glass-appearance='frosted'][data-glass-navbar-refraction='chromium']")
|
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('--glass-navbar-live-filter: none !important')
|
||||||
expect(reducedTransparencyRule).toContain('-webkit-backdrop-filter: none !important')
|
expect(reducedTransparencyRule).toContain('-webkit-backdrop-filter: none !important')
|
||||||
expect(reducedTransparencyRule).toContain('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).toContain('transition: none !important')
|
||||||
expect(reducedMotionRule).not.toContain('inset-block-start: 0 !important')
|
expect(reducedMotionRule).not.toContain('inset-block-start: 0 !important')
|
||||||
expect(reducedMotionRule).not.toContain('inset-inline: 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']
|
body[data-theme='glass']
|
||||||
.layout-wrapper.layout-navbar-floating-eligible.layout-navbar-away-from-top
|
.layout-wrapper.layout-navbar-floating-eligible.layout-navbar-away-from-top
|
||||||
.layout-navbar {
|
.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;
|
border: 0 !important;
|
||||||
-webkit-backdrop-filter: var(--glass-navbar-live-filter) !important;
|
-webkit-backdrop-filter: var(--glass-navbar-live-filter) !important;
|
||||||
backdrop-filter: var(--glass-navbar-live-filter) !important;
|
backdrop-filter: var(--glass-navbar-live-filter) !important;
|
||||||
background:
|
background: var(--glass-navbar-sheen), var(--glass-navbar-scrim) !important;
|
||||||
linear-gradient(145deg, rgba(255, 255, 255, 0.18), transparent 38%),
|
box-shadow: var(--glass-navbar-shadow) !important;
|
||||||
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;
|
|
||||||
inset-block-start: var(--shell-floating-navbar-inset) !important;
|
inset-block-start: var(--shell-floating-navbar-inset) !important;
|
||||||
inset-inline: var(--shell-floating-navbar-inset) !important;
|
inset-inline: var(--shell-floating-navbar-inset) !important;
|
||||||
}
|
}
|
||||||
@@ -1711,7 +1749,8 @@ html[data-theme='glass'][data-glass-quality='high']:is(
|
|||||||
body[data-theme='glass']
|
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-wrapper[data-glass-navbar-refraction='chromium'][data-glass-navbar-refraction-ready='true'].layout-navbar-floating-eligible.layout-navbar-away-from-top
|
||||||
.layout-navbar {
|
.layout-navbar {
|
||||||
--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(
|
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']
|
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-wrapper[data-glass-navbar-refraction='chromium'][data-glass-navbar-refraction-ready='true'].layout-navbar-floating-eligible.layout-navbar-away-from-top
|
||||||
.layout-navbar {
|
.layout-navbar {
|
||||||
--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']
|
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-wrapper.layout-navbar-floating-eligible.layout-navbar-away-from-top
|
||||||
.layout-navbar {
|
.layout-navbar {
|
||||||
background:
|
background:
|
||||||
linear-gradient(145deg, rgba(255, 255, 255, 0.18), transparent 38%),
|
var(--glass-navbar-sheen),
|
||||||
linear-gradient(rgba(var(--glass-material-accent-rgb), 0.09), rgba(var(--glass-material-accent-rgb), 0.035)),
|
linear-gradient(
|
||||||
linear-gradient(rgba(7, 14, 25, 0.08), rgba(7, 14, 25, 0.15)) !important;
|
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) {
|
@media (prefers-reduced-transparency: reduce) {
|
||||||
|
|||||||
@@ -1,5 +1,41 @@
|
|||||||
import { describe, expect, it } from 'vitest'
|
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', () => {
|
describe('createGlassNavbarDisplacementField', () => {
|
||||||
function pixelAt(field: ReturnType<typeof createGlassNavbarDisplacementField>, x: number, y: number) {
|
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', () => {
|
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.width).toBe(101)
|
||||||
expect(field.height).toBe(41)
|
expect(field.height).toBe(41)
|
||||||
@@ -27,38 +68,43 @@ describe('createGlassNavbarDisplacementField', () => {
|
|||||||
{ width: 127, height: 64, radius: 8 },
|
{ width: 127, height: 64, radius: 8 },
|
||||||
{ width: 127, height: 64, radius: 32 },
|
{ width: 127, height: 64, radius: 32 },
|
||||||
])('keeps two-dimensional sampling forward and inside the image for $width x $height r$radius', geometry => {
|
])('keeps two-dimensional sampling forward and inside the image for $width x $height r$radius', geometry => {
|
||||||
const field = createGlassNavbarDisplacementField(geometry)
|
for (const deformation of [0, 48, 100])
|
||||||
for (const scale of [-22, -34]) {
|
for (const translation of [0, 48, 100])
|
||||||
const source = (x: number, y: number) => {
|
for (const scale of [-22, -34]) {
|
||||||
const pixel = pixelAt(field, x, y)
|
const field = createGlassNavbarDisplacementField({
|
||||||
return [x + 0.5 + scale * (pixel[0] / 255 - 0.5), y + 0.5 + scale * (pixel[2] / 255 - 0.5)]
|
...geometry,
|
||||||
}
|
optics: getGlassNavbarOpticalResponse({ deformation, translation }),
|
||||||
let minimumDeterminant = Number.POSITIVE_INFINITY
|
})
|
||||||
let minimumX = Number.POSITIVE_INFINITY
|
const source = (x: number, y: number) => {
|
||||||
let minimumY = Number.POSITIVE_INFINITY
|
const pixel = pixelAt(field, x, y)
|
||||||
let maximumX = 0
|
return [x + 0.5 + scale * (pixel[0] / 255 - 0.5), y + 0.5 + scale * (pixel[2] / 255 - 0.5)]
|
||||||
let maximumY = 0
|
}
|
||||||
for (let y = 0; y < field.height; y += 1) {
|
let minimumDeterminant = Number.POSITIVE_INFINITY
|
||||||
for (let x = 0; x < field.width; x += 1) {
|
let minimumX = Number.POSITIVE_INFINITY
|
||||||
const point = source(x, y)
|
let minimumY = Number.POSITIVE_INFINITY
|
||||||
minimumX = Math.min(minimumX, point[0])
|
let maximumX = 0
|
||||||
minimumY = Math.min(minimumY, point[1])
|
let maximumY = 0
|
||||||
maximumX = Math.max(maximumX, point[0])
|
for (let y = 0; y < field.height; y += 1) {
|
||||||
maximumY = Math.max(maximumY, point[1])
|
for (let x = 0; x < field.width; x += 1) {
|
||||||
if (x === field.width - 1 || y === field.height - 1) continue
|
const point = source(x, y)
|
||||||
const nextX = source(x + 1, y)
|
minimumX = Math.min(minimumX, point[0])
|
||||||
const nextY = source(x, y + 1)
|
minimumY = Math.min(minimumY, point[1])
|
||||||
const determinant =
|
maximumX = Math.max(maximumX, point[0])
|
||||||
(nextX[0] - point[0]) * (nextY[1] - point[1]) - (nextY[0] - point[0]) * (nextX[1] - point[1])
|
maximumY = Math.max(maximumY, point[1])
|
||||||
minimumDeterminant = Math.min(minimumDeterminant, determinant)
|
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)
|
||||||
}
|
}
|
||||||
}
|
|
||||||
expect(minimumDeterminant).toBeGreaterThan(0.05)
|
|
||||||
expect(minimumX).toBeGreaterThanOrEqual(0)
|
|
||||||
expect(minimumY).toBeGreaterThanOrEqual(0)
|
|
||||||
expect(maximumX).toBeLessThanOrEqual(field.width)
|
|
||||||
expect(maximumY).toBeLessThanOrEqual(field.height)
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
|
|
||||||
it('clamps invalidly small geometry to a renderable pixel surface', () => {
|
it('clamps invalidly small geometry to a renderable pixel surface', () => {
|
||||||
@@ -84,10 +130,10 @@ describe('createGlassNavbarDisplacementField', () => {
|
|||||||
maximumVerticalStep = Math.max(maximumVerticalStep, 1 + current - previous)
|
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)))
|
for (let x = 0; x < 32; x += 1) maximumHorizontal = Math.max(maximumHorizontal, Math.abs(displacement(x, 32, 0)))
|
||||||
expect(maximumHorizontal).toBeGreaterThan(7)
|
expect(maximumHorizontal).toBeGreaterThan(1.5)
|
||||||
expect(maximumVertical).toBeLessThan(2.6)
|
expect(maximumVertical).toBeLessThan(0.5)
|
||||||
expect(minimumVerticalStep).toBeGreaterThan(0.59)
|
expect(minimumVerticalStep).toBeGreaterThan(0.85)
|
||||||
expect(maximumVerticalStep).toBeLessThan(1.41)
|
expect(maximumVerticalStep).toBeLessThan(1.15)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,9 @@
|
|||||||
|
import {
|
||||||
|
getGlassOpticalPresetParameters,
|
||||||
|
normalizeGlassOpticalStrength,
|
||||||
|
type GlassOpticalParameters,
|
||||||
|
} from '@/utils/glassOptics'
|
||||||
|
|
||||||
export interface GlassNavbarRefractionBrowserIdentity {
|
export interface GlassNavbarRefractionBrowserIdentity {
|
||||||
/** User-Agent Client Hints 暴露的浏览器品牌。 */
|
/** User-Agent Client Hints 暴露的浏览器品牌。 */
|
||||||
userAgentData?: {
|
userAgentData?: {
|
||||||
@@ -16,6 +22,32 @@ export interface GlassNavbarDisplacementGeometry {
|
|||||||
radius: number
|
radius: number
|
||||||
/** 折射表面的实际 CSS 像素宽度。 */
|
/** 折射表面的实际 CSS 像素宽度。 */
|
||||||
width: number
|
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 {
|
export interface GlassNavbarDisplacementField {
|
||||||
@@ -35,10 +67,8 @@ const HIGH_REFRACTION_SCALE_PX = 34
|
|||||||
const OUTER_NEUTRAL_GUARD_PX = 0.5
|
const OUTER_NEUTRAL_GUARD_PX = 0.5
|
||||||
const REFRACTION_BAND_PX = 24
|
const REFRACTION_BAND_PX = 24
|
||||||
// 峰值靠近外沿,内侧有足够距离释放放大率;对称波峰会在窄轮廓内反向采样。
|
// 峰值靠近外沿,内侧有足够距离释放放大率;对称波峰会在窄轮廓内反向采样。
|
||||||
const MAX_DISPLACEMENT_BAND_RATIO = 0.42
|
|
||||||
const PEAK_DEPTH_RATIO = 0.16
|
const PEAK_DEPTH_RATIO = 0.16
|
||||||
// 文本沿纵向滚动穿过长边时,限制字形高度的局部伸缩;短边保留更强的水平透镜。
|
const DEFAULT_NAVBAR_OPTICS = getGlassNavbarOpticalResponse(getGlassOpticalPresetParameters('clear', 'high', 'natural'))
|
||||||
const VERTICAL_DISPLACEMENT_BAND_RATIO = 0.1
|
|
||||||
|
|
||||||
function normalizePixelSize(value: number) {
|
function normalizePixelSize(value: number) {
|
||||||
return Number.isFinite(value) ? Math.max(1, Math.round(value)) : 1
|
return Number.isFinite(value) ? Math.max(1, Math.round(value)) : 1
|
||||||
@@ -70,12 +100,13 @@ function refractionProfile(depth: number, band: number, guard: number) {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* 生成圆角表面的法线位移场。
|
* 生成圆角表面的法线位移场。
|
||||||
* 外轮廓和内区都保持中性采样,避免折射在裁剪边界或主体内容区形成整带错位。
|
* 外轮廓回到中性;内部统一平移与局部透镜分开计算,不用弯曲字形代替平移。
|
||||||
*/
|
*/
|
||||||
export function createGlassNavbarDisplacementField({
|
export function createGlassNavbarDisplacementField({
|
||||||
height,
|
height,
|
||||||
radius,
|
radius,
|
||||||
width,
|
width,
|
||||||
|
optics = DEFAULT_NAVBAR_OPTICS,
|
||||||
}: GlassNavbarDisplacementGeometry): GlassNavbarDisplacementField {
|
}: GlassNavbarDisplacementGeometry): GlassNavbarDisplacementField {
|
||||||
const pixelWidth = normalizePixelSize(width)
|
const pixelWidth = normalizePixelSize(width)
|
||||||
const pixelHeight = normalizePixelSize(height)
|
const pixelHeight = normalizePixelSize(height)
|
||||||
@@ -104,14 +135,20 @@ export function createGlassNavbarDisplacementField({
|
|||||||
const cornerBand = Math.min(maximumBand, pixelRadius)
|
const cornerBand = Math.min(maximumBand, pixelRadius)
|
||||||
const bandWidth = cornerBand + (maximumBand - cornerBand) * straightWeight
|
const bandWidth = cornerBand + (maximumBand - cornerBand) * straightWeight
|
||||||
const outerGuard = Math.min(OUTER_NEUTRAL_GUARD_PX, bandWidth / 4)
|
const outerGuard = Math.min(OUTER_NEUTRAL_GUARD_PX, bandWidth / 4)
|
||||||
const channelAmplitude = (bandWidth * 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 profile = distanceInside < bandWidth ? refractionProfile(distanceInside, bandWidth, outerGuard) : 0
|
||||||
const verticalProgress = (distanceInside - outerGuard) / (bandWidth - outerGuard)
|
const verticalProgress = Math.min(1, (distanceInside - outerGuard) / (bandWidth - outerGuard))
|
||||||
const verticalProfile = Math.sin(Math.PI * verticalProgress) ** 2
|
const verticalProfile = Math.sin(Math.PI * verticalProgress) ** 2
|
||||||
const verticalAmplitude = (bandWidth * VERTICAL_DISPLACEMENT_BAND_RATIO * 255) / HIGH_REFRACTION_SCALE_PX
|
const verticalAmplitude = (bandWidth * optics.verticalRatio * 255) / HIGH_REFRACTION_SCALE_PX
|
||||||
const gradientX =
|
const gradientX =
|
||||||
roundedRectangleSignedDistance(sampleX + 0.5, sampleY, pixelWidth, pixelHeight, pixelRadius) -
|
roundedRectangleSignedDistance(sampleX + 0.5, sampleY, pixelWidth, pixelHeight, pixelRadius) -
|
||||||
roundedRectangleSignedDistance(sampleX - 0.5, sampleY, pixelWidth, pixelHeight, pixelRadius)
|
roundedRectangleSignedDistance(sampleX - 0.5, sampleY, pixelWidth, pixelHeight, pixelRadius)
|
||||||
@@ -120,9 +157,13 @@ export function createGlassNavbarDisplacementField({
|
|||||||
roundedRectangleSignedDistance(sampleX, sampleY - 0.5, pixelWidth, pixelHeight, pixelRadius)
|
roundedRectangleSignedDistance(sampleX, sampleY - 0.5, pixelWidth, pixelHeight, pixelRadius)
|
||||||
const gradientLength = Math.hypot(gradientX, gradientY) || 1
|
const gradientLength = Math.hypot(gradientX, gradientY) || 1
|
||||||
const offset = (y * pixelWidth + x) * 4
|
const offset = (y * pixelWidth + x) * 4
|
||||||
|
// 横向平移从边缘透镜退出后进入,避免两种回落梯度叠加导致局部反向采样。
|
||||||
|
const horizontalRamp = smoothstep(Math.max(0, Math.min(1, (edgeX - maximumBand) / 64)))
|
||||||
|
const verticalRamp = smoothstep(Math.min(1, (edgeY - outerGuard) / Math.max(1, Math.min(12, maximumBand))))
|
||||||
|
const translationChannel = (optics.translationPx * horizontalRamp * verticalRamp * 255) / HIGH_REFRACTION_SCALE_PX
|
||||||
|
|
||||||
pixels[offset] = clampChannel(
|
pixels[offset] = clampChannel(
|
||||||
DISPLACEMENT_NEUTRAL_CHANNEL + (channelAmplitude * gradientX * profile) / gradientLength,
|
DISPLACEMENT_NEUTRAL_CHANNEL + (channelAmplitude * gradientX * profile) / gradientLength + translationChannel,
|
||||||
)
|
)
|
||||||
pixels[offset + 2] = clampChannel(
|
pixels[offset + 2] = clampChannel(
|
||||||
DISPLACEMENT_NEUTRAL_CHANNEL + (verticalAmplitude * gradientY * verticalProfile) / gradientLength,
|
DISPLACEMENT_NEUTRAL_CHANNEL + (verticalAmplitude * gradientY * verticalProfile) / gradientLength,
|
||||||
|
|||||||
Reference in New Issue
Block a user