diff --git a/src/components/theme/GlassNavbarRefractionDefs.vue b/src/components/theme/GlassNavbarRefractionDefs.vue index d8412e95..ec60b130 100644 --- a/src/components/theme/GlassNavbarRefractionDefs.vue +++ b/src/components/theme/GlassNavbarRefractionDefs.vue @@ -3,6 +3,7 @@ import type { Ref } from 'vue' import { createGlassNavbarDisplacementMap, getGlassNavbarOpticalResponse, + getGlassSidebarOpticalResponse, NEUTRAL_GLASS_NAVBAR_DISPLACEMENT_MAP, } from '@/utils/glassNavbarRefraction' import { useEffectiveGlassSettings } from '@/composables/useThemeCustomizer' @@ -214,7 +215,13 @@ function readDisplacementGeometry(surface: NavigationSurface) { const width = Math.max(1, Math.round(bounds.width)) const radius = Number.isFinite(borderRadius) ? borderRadius : state.defaultGeometry.radius - const optics = opticalResponse.value + const optics = + surface === 'sidebar' + ? getGlassSidebarOpticalResponse({ + deformation: settings.value.glassDeformationStrength, + translation: settings.value.glassTranslationStrength, + }) + : opticalResponse.value return { height, radius, @@ -241,7 +248,7 @@ async function syncDisplacementMap(surface: NavigationSurface) { try { if (state.cachedGeometry !== geometryKey) { if (state.failedGeometry === geometryKey) return - const map = createGlassNavbarDisplacementMap({ height, radius, width, optics }) + const map = createGlassNavbarDisplacementMap({ height, radius, width, optics, surface }) if (map === NEUTRAL_GLASS_NAVBAR_DISPLACEMENT_MAP) { state.failedGeometry = geometryKey invalidateDisplacementMap(surface) diff --git a/src/components/theme/__tests__/GlassNavbarRefractionDefs.spec.ts b/src/components/theme/__tests__/GlassNavbarRefractionDefs.spec.ts index b4d41024..07ac95c2 100644 --- a/src/components/theme/__tests__/GlassNavbarRefractionDefs.spec.ts +++ b/src/components/theme/__tests__/GlassNavbarRefractionDefs.spec.ts @@ -1,7 +1,7 @@ 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 { createGlassNavbarDisplacementMap, getGlassSidebarOpticalResponse } from '@/utils/glassNavbarRefraction' import { ref } from 'vue' vi.mock('@/utils/glassNavbarRefraction', async importOriginal => ({ @@ -190,7 +190,13 @@ describe('GlassNavbarRefractionDefs', () => { expect.objectContaining({ width: 1423, height: 64, radius: 16 }), ) expect(createGlassNavbarDisplacementMap).toHaveBeenLastCalledWith( - expect.objectContaining({ width: 260, height: 800, radius: 0 }), + expect.objectContaining({ + width: 260, + height: 800, + radius: 0, + surface: 'sidebar', + optics: getGlassSidebarOpticalResponse({ deformation: 48, translation: 48 }), + }), ) expect(shell.dataset.glassNavbarRefractionReady).toBe('false') expectReadyForSidebar(260) diff --git a/src/styles/__tests__/glassOverlayMaterial.spec.ts b/src/styles/__tests__/glassOverlayMaterial.spec.ts index fd64c09a..fe84a23a 100644 --- a/src/styles/__tests__/glassOverlayMaterial.spec.ts +++ b/src/styles/__tests__/glassOverlayMaterial.spec.ts @@ -4,6 +4,16 @@ import { cwd } from 'node:process' import { describe, expect, it } from 'vitest' describe('glass overlay material styles', () => { + it('shares the soft contour between content cards and detached navigation', () => { + const styles = readFileSync(resolve(cwd(), 'src/styles/themes/_glass-v3.scss'), 'utf8') + + expect(styles).toContain('box-shadow: var(--glass-v3-surface-edge), var(--glass-v3-shadow)') + expect(styles).toContain('box-shadow: var(--glass-v3-surface-edge), var(--glass-v3-navigation-shadow)') + expect(styles).toMatch( + /&::before\s*\{[\s\S]*?border-radius: inherit;[\s\S]*?box-shadow: var\(--glass-v3-surface-edge\)/u, + ) + }) + it('reserves space below detached desktop navigation and follows the compact theme radius', () => { const styles = readFileSync(resolve(cwd(), 'src/styles/themes/_glass-v3.scss'), 'utf8') diff --git a/src/styles/themes/_glass-v3.scss b/src/styles/themes/_glass-v3.scss index 9407d23d..9a547e1b 100644 --- a/src/styles/themes/_glass-v3.scss +++ b/src/styles/themes/_glass-v3.scss @@ -13,6 +13,7 @@ --glass-v3-navigation-radius: var(--app-field-radius, 16px); --glass-v3-navigation-inset: 8px; --glass-v3-navigation-content-gap: 16px; + --glass-v3-navigation-shadow: 0 8px 24px rgba(0, 0, 0, 0.1); --glass-v3-navigation-blur: clamp( 0.65px, calc(0.9px + var(--glass-surface-density, 0.62) * 0.6px - var(--glass-background-visibility, 0.58) * 0.25px), @@ -45,6 +46,16 @@ } body[data-theme='glass'] { + .v-card, + .layout-navbar, + .layout-vertical-nav::before { + // 在表面上解析反光强度,卡片 hover 的局部参数不能被根节点预先求值。 + --glass-v3-surface-edge: + inset 1px 1px 3px rgba(255, 255, 255, calc(var(--glass-v3-rim) * 0.3)), + inset -1px -1px 3px rgba(255, 255, 255, calc(var(--glass-v3-rim) * 0.22)), + inset 0 0 5px rgba(255, 255, 255, calc(var(--glass-v3-rim) * 0.1)); + } + // 已有业务类是语义材料的适配入口;海报整图、内部图表和菜单不承载第二层卡片材料。 .layout-page-content .v-card:not( @@ -63,12 +74,7 @@ .dashboard-grid-content-measure > :first-child > .v-card { border-radius: var(--app-theme-surface-radius, 20px) !important; background: var(--glass-v3-card-background) !important; - box-shadow: - inset 0 1px 0 rgba(255, 255, 255, var(--glass-v3-rim)), - inset 1px 0 0 rgba(255, 255, 255, calc(var(--glass-v3-rim) * 0.6)), - inset -1px -2px 2px rgba(0, 0, 0, 0.16), - inset 0 0 0 1px rgba(255, 255, 255, calc(var(--glass-v3-rim) * 0.25)), - var(--glass-v3-shadow) !important; + box-shadow: var(--glass-v3-surface-edge), var(--glass-v3-shadow) !important; } .layout-page-content @@ -174,12 +180,21 @@ .layout-vertical-nav { border-radius: var(--glass-v3-navigation-radius); - box-shadow: var(--glass-v3-shadow) !important; + box-shadow: var(--glass-v3-navigation-shadow) !important; block-size: calc(100% - 2 * var(--glass-v3-navigation-inset)); inline-size: calc(var(--glass-v3-nav-reserved-width) - var(--glass-v3-navigation-inset)) !important; inset-block-start: var(--glass-v3-navigation-inset); inset-inline-start: var(--glass-v3-navigation-inset); overflow: clip; + + &::before { + // 材质与采样外壳共用圆角,内沿高光必须绕四角延续,不能绘制直边后再裁断。 + border-radius: inherit; + border-inline-end: 0; + background: var(--glass-v3-card-background) !important; + // 固定导航共用柔和的掠射光,四角厚度由透镜承担,不叠加等宽硬质亮线。 + box-shadow: var(--glass-v3-surface-edge) !important; + } } &.layout-vertical-nav-collapsed .layout-vertical-nav.hovered { @@ -188,6 +203,7 @@ .layout-navbar { border-radius: var(--glass-v3-navigation-radius) !important; + box-shadow: var(--glass-v3-surface-edge), var(--glass-v3-navigation-shadow) !important; inline-size: calc(100% - var(--glass-v3-nav-reserved-width) - 2 * var(--glass-v3-navigation-inset)) !important; inset-block-start: var(--glass-v3-navigation-inset) !important; inset-inline-start: calc(var(--glass-v3-nav-reserved-width) + var(--glass-v3-navigation-inset)) !important; @@ -237,18 +253,6 @@ background: var(--glass-v3-card-background) !important; backdrop-filter: var(--glass-navbar-live-filter) !important; -webkit-backdrop-filter: var(--glass-navbar-live-filter) !important; - box-shadow: - inset 0 -1px 0 rgba(255, 255, 255, var(--glass-v3-rim)), - 0 8px 24px rgba(0, 0, 0, 0.1) !important; - } - - .layout-vertical-nav::before { - border-inline-end: 0; - background: var(--glass-v3-card-background) !important; - box-shadow: - inset -1px 0 0 rgba(255, 255, 255, var(--glass-v3-rim)), - inset -3px 0 4px rgba(255, 255, 255, 0.035), - 8px 0 24px rgba(0, 0, 0, 0.08) !important; } } @@ -266,16 +270,6 @@ } } - // 稳定背板已承担背景吸收,侧栏用明亮散射层呈现厚度,避免再覆盖一层深色渐变。 - html[data-theme='glass'][data-glass-appearance='frosted'] body[data-theme='glass'] { - .layout-wrapper[data-shell-mode='desktop'] .layout-vertical-nav::before { - background: var(--glass-v3-card-background) !important; - box-shadow: - inset -1px 0 0 rgba(255, 255, 255, 0.32), - 8px 0 24px rgba(0, 0, 0, 0.07) !important; - } - } - // 圆角在脱离边缘的首帧采用最终值,真实inset或transform继续使用同一150ms运动节奏。 html[data-theme='glass']:is( [data-glass-appearance='clear'], diff --git a/src/utils/__tests__/glassNavbarRefraction.spec.ts b/src/utils/__tests__/glassNavbarRefraction.spec.ts index df8a5d62..6133cc51 100644 --- a/src/utils/__tests__/glassNavbarRefraction.spec.ts +++ b/src/utils/__tests__/glassNavbarRefraction.spec.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest' import { createGlassNavbarDisplacementField, getGlassNavbarOpticalResponse, + getGlassSidebarOpticalResponse, supportsGlassNavbarLiveRefraction, } from '@/utils/glassNavbarRefraction' @@ -45,6 +46,16 @@ describe('getGlassNavbarOpticalResponse', () => { }) }) +describe('getGlassSidebarOpticalResponse', () => { + it.each([0, 50, 99, 100])('keeps the same cubic sliders and equal corner axes at %s', strength => { + const parameters = Object.freeze({ deformation: strength, translation: strength }) + const optics = getGlassSidebarOpticalResponse(parameters) + expect(optics.horizontalRatio).toBe(optics.verticalRatio) + expect(optics.horizontalRatio).toBeLessThanOrEqual(0.3) + expect(optics.translationPx).toBe(getGlassNavbarOpticalResponse(parameters).translationPx) + }) +}) + describe('createGlassNavbarDisplacementField', () => { function pixelAt(field: ReturnType, x: number, y: number) { const offset = (y * field.width + x) * 4 @@ -83,13 +94,19 @@ describe('createGlassNavbarDisplacementField', () => { { width: 252, height: 846, radius: 24 }, { width: 60, height: 846, radius: 8 }, { width: 60, height: 846, radius: 24 }, + ...[60, 252].flatMap(width => + [0, 8, 12, 16, 20, 24].map(radius => ({ width, height: 180, radius, surface: 'sidebar' as const })), + ), ])('keeps two-dimensional sampling forward and inside the image for $width x $height r$radius', 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 }), + optics: + 'surface' in geometry + ? getGlassSidebarOpticalResponse({ deformation, translation }) + : getGlassNavbarOpticalResponse({ deformation, translation }), }) const source = (x: number, y: number) => { const pixel = pixelAt(field, x, y) @@ -123,6 +140,31 @@ describe('createGlassNavbarDisplacementField', () => { } }) + it.each([8, 12, 16, 20, 24])('turns all four sidebar corners with one radial profile at radius %s', radius => { + const field = createGlassNavbarDisplacementField({ + width: 252, + height: 180, + radius, + surface: 'sidebar', + optics: getGlassSidebarOpticalResponse({ deformation: 50, translation: 0 }), + }) + for (let y = 0; y < radius; y += 1) + for (let x = 0; x < radius; x += 1) { + const topLeft = pixelAt(field, x, y) + const topRight = pixelAt(field, field.width - 1 - x, y) + const bottomLeft = pixelAt(field, x, field.height - 1 - y) + const bottomRight = pixelAt(field, field.width - 1 - x, field.height - 1 - y) + expect(topLeft[0] + topRight[0]).toBe(256) + expect(topLeft[2] + bottomLeft[2]).toBe(256) + expect(bottomRight[0]).toBe(topRight[0]) + expect(bottomRight[2]).toBe(bottomLeft[2]) + expect(pixelAt(field, y, x)[0]).toBe(topLeft[2]) + } + expect(pixelAt(field, 126, 2)[2]).toBeLessThan(128) + expect(pixelAt(field, 126, 177)[2]).toBeGreaterThan(128) + expect(pixelAt(field, 126, 90)).toEqual([128, 128, 128, 255]) + }) + it('keeps a fixed rectangle optically active without substituting a rounded corner', () => { const field = createGlassNavbarDisplacementField({ height: 800, diff --git a/src/utils/glassNavbarRefraction.ts b/src/utils/glassNavbarRefraction.ts index ec2ff56e..835533b8 100644 --- a/src/utils/glassNavbarRefraction.ts +++ b/src/utils/glassNavbarRefraction.ts @@ -16,26 +16,38 @@ export interface GlassNavbarRefractionBrowserIdentity { } export interface GlassNavbarDisplacementGeometry { + /** 侧栏使用四边等向的窄透镜;省略时保留顶栏的横向阅读保护方案。 */ + surface?: 'navbar' | 'sidebar' /** 折射表面的实际 CSS 像素高度。 */ height: number /** 最终可见外轮廓的圆角半径。 */ radius: number /** 折射表面的实际 CSS 像素宽度。 */ width: number - /** 由当前生效滑杆计算的顶栏光学响应;省略时采用清透自然默认值。 */ + /** 由当前生效滑杆计算的导航光学响应;省略时采用清透自然默认值。 */ optics?: GlassNavbarOpticalResponse } -/** 顶栏局部取样预算,不包含共享 renderer 的流动、尾波与惯性。 */ +/** 导航局部取样预算,不包含共享 renderer 的流动、尾波与惯性。 */ export interface GlassNavbarOpticalResponse { /** 横向边缘峰值位移与轮廓带宽之比。 */ horizontalRatio: number - /** 纵向峰值位移与轮廓带宽之比,严格小于横向以保护字形高度。 */ + /** 纵向峰值位移与轮廓带宽之比;顶栏弱于横向,侧栏沿四边等向响应。 */ verticalRatio: number /** 主体内容统一向右显示的 CSS 像素偏移,外轮廓平缓回零。 */ translationPx: number } +/** 侧栏的阅读区由窄边带保护,圆角处两轴使用相同强度,避免转角时透镜厚度消失。 */ +export function getGlassSidebarOpticalResponse( + parameters: Pick, +): GlassNavbarOpticalResponse { + const navbar = getGlassNavbarOpticalResponse(parameters) + // 最大位移不超过带宽的 30%,为圆角内侧的取样回落保留单调余量。 + const ratio = (navbar.horizontalRatio / 0.42) * 0.3 + return { horizontalRatio: ratio, verticalRatio: ratio, translationPx: navbar.translationPx } +} + /** 导航以低中段可读性为优先,高段保留完整位移预算;不改写共享参数或材质响应。 */ export function getGlassNavbarOpticalResponse( parameters: Pick, @@ -108,7 +120,10 @@ export function createGlassNavbarDisplacementField({ height, radius, width, - optics = DEFAULT_NAVBAR_OPTICS, + surface = 'navbar', + optics = surface === 'sidebar' + ? getGlassSidebarOpticalResponse(getGlassOpticalPresetParameters('clear', 'high', 'natural')) + : DEFAULT_NAVBAR_OPTICS, }: GlassNavbarDisplacementGeometry): GlassNavbarDisplacementField { const pixelWidth = normalizePixelSize(width) const pixelHeight = normalizePixelSize(height) @@ -116,7 +131,11 @@ export function createGlassNavbarDisplacementField({ const pixelRadius = Number.isFinite(radius) ? Math.max(0, Math.min(maxRadius, radius)) : 0 // 直角固定表面没有圆角半径可供推导,仍使用受最短边约束的直边带;radius=0 不能被当成无折射。 const radiusBand = pixelRadius > 0 ? pixelRadius * 1.5 : REFRACTION_BAND_PX - const maximumBand = Math.min(REFRACTION_BAND_PX, radiusBand, Math.min(pixelWidth, pixelHeight) / 2) + // 侧栏边带不穿过圆角圆心,四角法线能连续转向且不会在内侧形成聚焦尖点。 + const maximumBand = + surface === 'sidebar' + ? Math.min(12, pixelRadius || 12, Math.min(pixelWidth, pixelHeight) / 2) + : Math.min(REFRACTION_BAND_PX, radiusBand, Math.min(pixelWidth, pixelHeight) / 2) const pixels = new Uint8ClampedArray(pixelWidth * pixelHeight * 4) for (let offset = 0; offset < pixels.length; offset += 4) { @@ -152,7 +171,10 @@ export function createGlassNavbarDisplacementField({ // 横向平移从边缘透镜退出后进入,避免两种回落梯度叠加导致局部反向采样。 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 verticalRamp = + surface === 'sidebar' + ? smoothstep(Math.max(0, Math.min(1, (edgeY - maximumBand) / 64))) + : smoothstep(Math.min(1, (edgeY - outerGuard) / Math.max(1, Math.min(12, maximumBand)))) const translationChannel = (optics.translationPx * horizontalRamp * verticalRamp * 255) / HIGH_REFRACTION_SCALE_PX if (pixelRadius === 0) { @@ -175,7 +197,8 @@ export function createGlassNavbarDisplacementField({ 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 verticalProfile = surface === 'sidebar' ? profile : Math.sin(Math.PI * verticalProgress) ** 2 const verticalAmplitude = (bandWidth * optics.verticalRatio * 255) / HIGH_REFRACTION_SCALE_PX const gradientX = roundedRectangleSignedDistance(sampleX + 0.5, sampleY, pixelWidth, pixelHeight, pixelRadius) -